Compare commits
13 Commits
6cc06390b2
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8aa7038ad
|
||
|
|
e887fa8946
|
||
|
|
d9c47ec030
|
||
|
|
4e8f4cbb10
|
||
|
|
1cc8bd5a4c
|
||
|
|
50f14c14d2
|
||
|
|
4f40850fd2
|
||
|
|
e4abf8dc9b
|
||
|
|
3500787851
|
||
|
|
c5225c96a5
|
||
|
|
aae09020e6
|
||
|
|
0cf49e6ef1
|
||
|
|
7545457813
|
@@ -3,6 +3,7 @@
|
||||
# =============================================================================
|
||||
# Executor: Docker (LXC 108 — 71.85.90.8)
|
||||
# Build/test jobs run in node:18 containers.
|
||||
# Release: v2.1.0
|
||||
# Deploy jobs run in alpine with SSH/rsync, targeting staging (71.85.90.9)
|
||||
# and production (71.85.90.6) via SSH.
|
||||
# =============================================================================
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -6,10 +6,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] — 2026-06-01
|
||||
## [2.2.0] — 2026-06-04
|
||||
|
||||
### Features
|
||||
|
||||
- **Group by Host toggle** on the Ivanti findings table — collapses duplicate assets (same hostname + IP) with multiple finding IDs into expandable host rows. Hosts with only one finding remain as flat rows. Toggle between grouped and flat views from the toolbar.
|
||||
- **CARD ownership tooltip on IP hover** — hover over any IP address in the findings table to see CARD asset ownership data (confirmed/unconfirmed/candidate teams) in an interactive tooltip. Results cached per session for instant re-display.
|
||||
- **CARD direct action modal** — click "Actions" in the CARD tooltip to open a full confirm/decline/redirect modal that works directly against the CARD API without needing a queue item.
|
||||
- **Inline view panel** in the Archer Template Manager with per-section copy buttons
|
||||
- **Queue item redirect in place** — pending queue items can now be redirected without duplicating
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Improve CARD decline error diagnostics and prevent accidental modal dismiss
|
||||
- CARD teams fetch retries silently up to 3x on failure with increasing delay
|
||||
- Redirect dropdowns show owner-data teams as fallback when the full teams API fails
|
||||
- CARD tooltip uses quick mode (CTEC suffix only, 15s timeout) to avoid multi-minute waits
|
||||
- Timeouts (504) are not cached — re-hover will retry the lookup
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] — 2026-06-06
|
||||
|
||||
### Features
|
||||
|
||||
- **Archer Template Library** — new template management system for Archer Risk Acceptance forms. Store static content (Environment Overview, Segmentation, Mitigating Controls) organized by Vendor > Platform > Model. Full CRUD with clone, search/filter, and per-section copy-to-clipboard. Accessible from the nav drawer (Template Mgr) and integrated into the Ivanti Queue for Archer workflow items.
|
||||
- **Estimated resolution date per metric** — the compliance asset sidebar now shows each noncompliant metric's estimated resolution date at the top of its section, in `YYYY-MM-DD` format, with placeholders for metrics that have no date set or an invalid date (closes #20)
|
||||
- **CARD Action Modal** with full owner context
|
||||
- **Granite Loader Sheet generator** with CARD enrichment, plus a Loader Sheet button on the Reporting page queue panel
|
||||
|
||||
@@ -252,8 +252,8 @@ async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
|
||||
/**
|
||||
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
||||
*/
|
||||
async function getOwner(assetId) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
|
||||
async function getOwner(assetId, options) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
@@ -298,35 +298,54 @@ async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
|
||||
/**
|
||||
* Resolve a bare IP address to a full CARD asset ID by trying known suffixes.
|
||||
* Returns the first asset ID that returns a valid owner record, or null if none found.
|
||||
*
|
||||
* @param {string} ip - IP address or existing asset ID
|
||||
* @param {object} [options] - { quick: true } to only try CTEC suffix (for tooltip/hover use)
|
||||
*/
|
||||
async function resolveAssetId(ip) {
|
||||
const SUFFIXES = ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
||||
async function resolveAssetId(ip, options) {
|
||||
const quick = options && options.quick;
|
||||
const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
||||
const timeout = quick ? 15000 : undefined; // 15s timeout for quick mode
|
||||
const trimmedIp = (ip || '').trim();
|
||||
if (!trimmedIp) return null;
|
||||
|
||||
// If it already has a suffix (contains a dash followed by letters), use as-is
|
||||
if (/\d+-[A-Z]+$/i.test(trimmedIp)) {
|
||||
const result = await getOwner(trimmedIp);
|
||||
if (result.ok) return trimmedIp;
|
||||
try {
|
||||
const result = await getOwner(trimmedIp, timeout ? { timeout } : undefined);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (err) {
|
||||
// Timeout — throw so caller can distinguish from "not found"
|
||||
if (quick && err.message && err.message.includes('timed out')) {
|
||||
throw new Error('CARD_TIMEOUT');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try each suffix
|
||||
for (const suffix of SUFFIXES) {
|
||||
const candidate = `${trimmedIp}-${suffix}`;
|
||||
try {
|
||||
const result = await getOwner(candidate);
|
||||
const result = await getOwner(candidate, timeout ? { timeout } : undefined);
|
||||
if (result.ok) return candidate;
|
||||
} catch (_) {
|
||||
} catch (err) {
|
||||
// Timeout — throw so caller can distinguish from "not found"
|
||||
if (quick && err.message && err.message.includes('timed out')) {
|
||||
throw new Error('CARD_TIMEOUT');
|
||||
}
|
||||
// Continue to next suffix
|
||||
}
|
||||
}
|
||||
|
||||
// Try bare IP as last resort
|
||||
try {
|
||||
const result = await getOwner(trimmedIp);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (_) {
|
||||
// Not found
|
||||
// Try bare IP as last resort (skip in quick mode to avoid extra delay)
|
||||
if (!quick) {
|
||||
try {
|
||||
const result = await getOwner(trimmedIp);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (_) {
|
||||
// Not found
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
60
backend/migrations/add_archer_templates_table.js
Normal file
60
backend/migrations/add_archer_templates_table.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Migration: Add archer_templates table for the Archer Template Library feature
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting archer_templates table migration...');
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS archer_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
vendor VARCHAR(100) NOT NULL,
|
||||
platform VARCHAR(100) NOT NULL,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
environment_overview TEXT NOT NULL DEFAULT '',
|
||||
segmentation TEXT NOT NULL DEFAULT '',
|
||||
mitigating_controls TEXT NOT NULL DEFAULT '',
|
||||
additional_info TEXT NOT NULL DEFAULT '',
|
||||
charter_network_banner TEXT NOT NULL DEFAULT '',
|
||||
data_classification TEXT NOT NULL DEFAULT '',
|
||||
charter_network TEXT NOT NULL DEFAULT '',
|
||||
additional_access_list TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
console.log('✓ archer_templates table created (or already exists)');
|
||||
|
||||
// Case-insensitive uniqueness on trimmed vendor/platform/model
|
||||
await pool.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_archer_templates_unique_combo
|
||||
ON archer_templates (LOWER(TRIM(vendor)), LOWER(TRIM(platform)), LOWER(TRIM(model)))
|
||||
`);
|
||||
console.log('✓ idx_archer_templates_unique_combo index created (or already exists)');
|
||||
|
||||
// Indexes for list query performance
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_archer_templates_vendor
|
||||
ON archer_templates(vendor)
|
||||
`);
|
||||
console.log('✓ idx_archer_templates_vendor index created (or already exists)');
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_archer_templates_platform
|
||||
ON archer_templates(platform)
|
||||
`);
|
||||
console.log('✓ idx_archer_templates_platform index created (or already exists)');
|
||||
|
||||
console.log('Migration complete.');
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
// Self-execute when run directly
|
||||
if (require.main === module) {
|
||||
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const POSTGRES_MIGRATIONS = [
|
||||
'add_multi_item_jira_ticket.js',
|
||||
'drop_jira_status_check_constraint.js',
|
||||
'add_compliance_history_metric_id.js',
|
||||
'add_archer_templates_table.js',
|
||||
];
|
||||
|
||||
async function runAll() {
|
||||
|
||||
543
backend/routes/archerTemplates.js
Normal file
543
backend/routes/archerTemplates.js
Normal file
@@ -0,0 +1,543 @@
|
||||
// routes/archerTemplates.js
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
// Section fields and their max length
|
||||
const SECTION_FIELDS = [
|
||||
'environment_overview',
|
||||
'segmentation',
|
||||
'mitigating_controls',
|
||||
'additional_info',
|
||||
'charter_network_banner',
|
||||
'data_classification',
|
||||
'charter_network',
|
||||
'additional_access_list'
|
||||
];
|
||||
const SECTION_MAX_LENGTH = 10000;
|
||||
|
||||
function createArcherTemplatesRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// --- Hierarchy endpoints (MUST be defined before /:id to avoid route conflicts) ---
|
||||
|
||||
/**
|
||||
* GET /api/archer-templates/hierarchy/vendors
|
||||
*
|
||||
* Returns a sorted array of distinct vendor names across all templates.
|
||||
*
|
||||
* @returns {string[]} 200 - Array of vendor names sorted alphabetically
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.get('/hierarchy/vendors', requireAuth(), async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT vendor FROM archer_templates ORDER BY vendor ASC'
|
||||
);
|
||||
res.json(rows.map(r => r.vendor));
|
||||
} catch (err) {
|
||||
console.error('Error fetching template vendors:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/archer-templates/hierarchy/platforms
|
||||
*
|
||||
* Returns a sorted array of distinct platform names for a given vendor.
|
||||
*
|
||||
* @query {string} vendor - (required) The vendor to filter platforms by
|
||||
* @returns {string[]} 200 - Array of platform names sorted alphabetically
|
||||
* @returns {object} 400 - { error: 'vendor query parameter is required' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.get('/hierarchy/platforms', requireAuth(), async (req, res) => {
|
||||
const { vendor } = req.query;
|
||||
if (!vendor) {
|
||||
return res.status(400).json({ error: 'vendor query parameter is required' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT platform FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) ORDER BY platform ASC',
|
||||
[vendor]
|
||||
);
|
||||
res.json(rows.map(r => r.platform));
|
||||
} catch (err) {
|
||||
console.error('Error fetching template platforms:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/archer-templates/hierarchy/models
|
||||
*
|
||||
* Returns a sorted array of distinct model names for a given vendor and platform.
|
||||
*
|
||||
* @query {string} vendor - (required) The vendor to filter by
|
||||
* @query {string} platform - (required) The platform to filter by
|
||||
* @returns {string[]} 200 - Array of model names sorted alphabetically
|
||||
* @returns {object} 400 - { error: 'Missing required query parameters: ...' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.get('/hierarchy/models', requireAuth(), async (req, res) => {
|
||||
const { vendor, platform } = req.query;
|
||||
const missing = [];
|
||||
if (!vendor) missing.push('vendor');
|
||||
if (!platform) missing.push('platform');
|
||||
if (missing.length > 0) {
|
||||
return res.status(400).json({ error: `Missing required query parameters: ${missing.join(', ')}` });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT model FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) AND LOWER(TRIM(platform)) = LOWER(TRIM($2)) ORDER BY model ASC',
|
||||
[vendor, platform]
|
||||
);
|
||||
res.json(rows.map(r => r.model));
|
||||
} catch (err) {
|
||||
console.error('Error fetching template models:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Core CRUD endpoints ---
|
||||
|
||||
/**
|
||||
* POST /api/archer-templates
|
||||
*
|
||||
* Creates a new Archer template with vendor/platform/model hierarchy and section content.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @body {string} vendor - (required) Vendor name, 1-100 chars after trim
|
||||
* @body {string} platform - (required) Platform name, 1-100 chars after trim
|
||||
* @body {string} model - (required) Model name, 1-100 chars after trim
|
||||
* @body {string} [environment_overview] - Section content, max 10,000 chars
|
||||
* @body {string} [segmentation] - Section content, max 10,000 chars
|
||||
* @body {string} [mitigating_controls] - Section content, max 10,000 chars
|
||||
* @body {string} [additional_info] - Section content, max 10,000 chars
|
||||
* @body {string} [charter_network_banner] - Section content, max 10,000 chars
|
||||
* @body {string} [data_classification] - Section content, max 10,000 chars
|
||||
* @body {string} [charter_network] - Section content, max 10,000 chars
|
||||
* @body {string} [additional_access_list] - Section content, max 10,000 chars
|
||||
* @returns {object} 201 - The created template record (all columns)
|
||||
* @returns {object} 400 - { error: 'validation message' }
|
||||
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { vendor, platform, model } = req.body;
|
||||
|
||||
// Validate vendor, platform, model — required, 1-100 chars after trim, non-empty after trim
|
||||
const errors = [];
|
||||
for (const [field, value] of [['vendor', vendor], ['platform', platform], ['model', model]]) {
|
||||
if (value === undefined || value === null || typeof value !== 'string' || value.trim().length === 0) {
|
||||
errors.push(`${field} is required`);
|
||||
} else if (value.trim().length > 100) {
|
||||
errors.push(`${field} must be 100 characters or fewer`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({ error: errors.join('; ') });
|
||||
}
|
||||
|
||||
// Validate section fields — max 10,000 chars each, default to empty string
|
||||
const sectionValues = {};
|
||||
for (const field of SECTION_FIELDS) {
|
||||
const val = req.body[field];
|
||||
if (val !== undefined && val !== null && typeof val === 'string') {
|
||||
if (val.length > SECTION_MAX_LENGTH) {
|
||||
return res.status(400).json({ error: `${field} must be 10,000 characters or fewer` });
|
||||
}
|
||||
sectionValues[field] = val;
|
||||
} else {
|
||||
sectionValues[field] = '';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO archer_templates (vendor, platform, model, environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
vendor.trim(),
|
||||
platform.trim(),
|
||||
model.trim(),
|
||||
sectionValues.environment_overview,
|
||||
sectionValues.segmentation,
|
||||
sectionValues.mitigating_controls,
|
||||
sectionValues.additional_info,
|
||||
sectionValues.charter_network_banner,
|
||||
sectionValues.data_classification,
|
||||
sectionValues.charter_network,
|
||||
sectionValues.additional_access_list,
|
||||
req.user.id
|
||||
]
|
||||
);
|
||||
|
||||
// Fire-and-forget audit log
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'template_created',
|
||||
entityType: 'archer_template',
|
||||
entityId: String(rows[0].id),
|
||||
details: { vendor: vendor.trim(), platform: platform.trim(), model: model.trim() },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
||||
}
|
||||
console.error('Error creating template:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/archer-templates
|
||||
*
|
||||
* Lists all templates with optional search and exact-match filters.
|
||||
* Results are sorted by vendor, platform, model (ascending).
|
||||
*
|
||||
* @query {string} [search] - Substring search across vendor, platform, and model (ILIKE)
|
||||
* @query {string} [vendor] - Exact-match filter on vendor (case-insensitive)
|
||||
* @query {string} [platform] - Exact-match filter on platform (case-insensitive)
|
||||
* @query {string} [model] - Exact-match filter on model (case-insensitive)
|
||||
* @returns {object[]} 200 - Array of template records sorted by vendor/platform/model
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
const { search, vendor, platform, model } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM archer_templates WHERE 1=1';
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Search — ILIKE substring match across vendor, platform, model
|
||||
const trimmedSearch = search ? search.trim() : '';
|
||||
if (trimmedSearch.length > 0) {
|
||||
query += ` AND (vendor ILIKE $${paramIndex} OR platform ILIKE $${paramIndex} OR model ILIKE $${paramIndex})`;
|
||||
params.push(`%${trimmedSearch}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Exact-match filters (case-insensitive via LOWER/TRIM)
|
||||
if (vendor) {
|
||||
query += ` AND LOWER(TRIM(vendor)) = LOWER(TRIM($${paramIndex}))`;
|
||||
params.push(vendor);
|
||||
paramIndex++;
|
||||
}
|
||||
if (platform) {
|
||||
query += ` AND LOWER(TRIM(platform)) = LOWER(TRIM($${paramIndex}))`;
|
||||
params.push(platform);
|
||||
paramIndex++;
|
||||
}
|
||||
if (model) {
|
||||
query += ` AND LOWER(TRIM(model)) = LOWER(TRIM($${paramIndex}))`;
|
||||
params.push(model);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
query += ' ORDER BY vendor ASC, platform ASC, model ASC';
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(query, params);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('Error fetching templates:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/archer-templates/:id/clone
|
||||
*
|
||||
* Clones an existing template's section content into a new template with different
|
||||
* vendor/platform/model hierarchy values. Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - The ID of the source template to clone from
|
||||
* @body {string} vendor - (required) New vendor name, 1-100 chars after trim
|
||||
* @body {string} platform - (required) New platform name, 1-100 chars after trim
|
||||
* @body {string} model - (required) New model name, 1-100 chars after trim
|
||||
* @returns {object} 201 - The newly created cloned template record
|
||||
* @returns {object} 400 - { error: 'validation message' }
|
||||
* @returns {object} 404 - { error: 'Template not found' }
|
||||
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.post('/:id/clone', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { vendor, platform, model } = req.body;
|
||||
|
||||
// Validate vendor, platform, model — required, 1-100 chars after trim, non-empty after trim
|
||||
const errors = [];
|
||||
for (const [field, value] of [['vendor', vendor], ['platform', platform], ['model', model]]) {
|
||||
if (value === undefined || value === null || typeof value !== 'string' || value.trim().length === 0) {
|
||||
errors.push(`${field} is required`);
|
||||
} else if (value.trim().length > 100) {
|
||||
errors.push(`${field} must be 100 characters or fewer`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({ error: errors.join('; ') });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify source template exists
|
||||
const { rows: sourceRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||
if (sourceRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
const source = sourceRows[0];
|
||||
|
||||
// INSERT copying all 8 section fields from source with new hierarchy values
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO archer_templates (vendor, platform, model, environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
vendor.trim(),
|
||||
platform.trim(),
|
||||
model.trim(),
|
||||
source.environment_overview,
|
||||
source.segmentation,
|
||||
source.mitigating_controls,
|
||||
source.additional_info,
|
||||
source.charter_network_banner,
|
||||
source.data_classification,
|
||||
source.charter_network,
|
||||
source.additional_access_list,
|
||||
req.user.id
|
||||
]
|
||||
);
|
||||
|
||||
// Fire-and-forget audit log
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'template_cloned',
|
||||
entityType: 'archer_template',
|
||||
entityId: String(rows[0].id),
|
||||
details: { sourceId: Number(id), newId: rows[0].id, vendor: vendor.trim(), platform: platform.trim(), model: model.trim() },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
||||
}
|
||||
console.error('Error cloning template:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/archer-templates/:id
|
||||
*
|
||||
* Fetches a single template by its ID.
|
||||
*
|
||||
* @param {number} id - The template ID
|
||||
* @returns {object} 200 - The template record
|
||||
* @returns {object} 404 - { error: 'Template not found' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.get('/:id', requireAuth(), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error('Error fetching template:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/archer-templates/:id
|
||||
*
|
||||
* Updates an existing template. Supports partial updates — only provided fields are changed.
|
||||
* Always updates `updated_at` to NOW(). Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - The template ID to update
|
||||
* @body {string} [vendor] - New vendor name, 1-100 chars after trim
|
||||
* @body {string} [platform] - New platform name, 1-100 chars after trim
|
||||
* @body {string} [model] - New model name, 1-100 chars after trim
|
||||
* @body {string} [environment_overview] - Section content, max 10,000 chars
|
||||
* @body {string} [segmentation] - Section content, max 10,000 chars
|
||||
* @body {string} [mitigating_controls] - Section content, max 10,000 chars
|
||||
* @body {string} [additional_info] - Section content, max 10,000 chars
|
||||
* @body {string} [charter_network_banner] - Section content, max 10,000 chars
|
||||
* @body {string} [data_classification] - Section content, max 10,000 chars
|
||||
* @body {string} [charter_network] - Section content, max 10,000 chars
|
||||
* @body {string} [additional_access_list] - Section content, max 10,000 chars
|
||||
* @returns {object} 200 - The updated template record
|
||||
* @returns {object} 400 - { error: 'validation message' }
|
||||
* @returns {object} 404 - { error: 'Template not found' }
|
||||
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
// Verify template exists
|
||||
const { rows: existingRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||
if (existingRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
const existing = existingRows[0];
|
||||
|
||||
// Validate provided hierarchy fields
|
||||
const errors = [];
|
||||
const updatedFields = {};
|
||||
const changedFieldNames = [];
|
||||
|
||||
for (const field of ['vendor', 'platform', 'model']) {
|
||||
const value = req.body[field];
|
||||
if (value !== undefined) {
|
||||
if (value === null || typeof value !== 'string' || value.trim().length === 0) {
|
||||
errors.push(`${field} is required`);
|
||||
} else if (value.trim().length > 100) {
|
||||
errors.push(`${field} must be 100 characters or fewer`);
|
||||
} else {
|
||||
updatedFields[field] = value.trim();
|
||||
if (value.trim() !== existing[field]) {
|
||||
changedFieldNames.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate provided section fields
|
||||
for (const field of SECTION_FIELDS) {
|
||||
const val = req.body[field];
|
||||
if (val !== undefined) {
|
||||
if (val !== null && typeof val === 'string') {
|
||||
if (val.length > SECTION_MAX_LENGTH) {
|
||||
errors.push(`${field} must be 10,000 characters or fewer`);
|
||||
} else {
|
||||
updatedFields[field] = val;
|
||||
if (val !== existing[field]) {
|
||||
changedFieldNames.push(field);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updatedFields[field] = '';
|
||||
if ('' !== existing[field]) {
|
||||
changedFieldNames.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({ error: errors.join('; ') });
|
||||
}
|
||||
|
||||
// Check uniqueness if vendor/platform/model changed (excluding self)
|
||||
const newVendor = updatedFields.vendor || existing.vendor;
|
||||
const newPlatform = updatedFields.platform || existing.platform;
|
||||
const newModel = updatedFields.model || existing.model;
|
||||
|
||||
if (updatedFields.vendor !== undefined || updatedFields.platform !== undefined || updatedFields.model !== undefined) {
|
||||
const { rows: conflictRows } = await pool.query(
|
||||
`SELECT id FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) AND LOWER(TRIM(platform)) = LOWER(TRIM($2)) AND LOWER(TRIM(model)) = LOWER(TRIM($3)) AND id != $4`,
|
||||
[newVendor, newPlatform, newModel, id]
|
||||
);
|
||||
if (conflictRows.length > 0) {
|
||||
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
||||
}
|
||||
}
|
||||
|
||||
// Build dynamic UPDATE SET clause for only provided fields
|
||||
const setClauses = [];
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const [field, value] of Object.entries(updatedFields)) {
|
||||
setClauses.push(`${field} = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Always set updated_at = NOW()
|
||||
setClauses.push(`updated_at = NOW()`);
|
||||
|
||||
// Execute update
|
||||
params.push(id);
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE archer_templates SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
// Fire-and-forget audit log
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'template_updated',
|
||||
entityType: 'archer_template',
|
||||
entityId: String(id),
|
||||
details: { changedFields: changedFieldNames },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error('Error updating template:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/archer-templates/:id
|
||||
*
|
||||
* Permanently deletes a template. Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - The template ID to delete
|
||||
* @returns {object} 200 - { message: 'Template deleted successfully' }
|
||||
* @returns {object} 404 - { error: 'Template not found' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
// Verify template exists
|
||||
const { rows: existingRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||
if (existingRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
const existing = existingRows[0];
|
||||
|
||||
// Delete the template
|
||||
await pool.query('DELETE FROM archer_templates WHERE id = $1', [id]);
|
||||
|
||||
// Fire-and-forget audit log
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'template_deleted',
|
||||
entityType: 'archer_template',
|
||||
entityId: String(id),
|
||||
details: { vendor: existing.vendor, platform: existing.platform, model: existing.model },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Template deleted successfully' });
|
||||
} catch (err) {
|
||||
console.error('Error deleting template:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createArcherTemplatesRouter;
|
||||
@@ -363,8 +363,8 @@ function createCardApiRouter() {
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
|
||||
const errMsg = 'update_token not found in owner record. The asset may have already been actioned or the owner record is in an unexpected state.';
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null, ownerResponse: ownerData }, ipAddress: req.ip });
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
@@ -508,8 +508,11 @@ function createCardApiRouter() {
|
||||
* confirm/decline/redirect operations.
|
||||
*
|
||||
* @param {string} ip - IP address (path parameter)
|
||||
* @query {string} [quick] - Set to "1" for quick mode (CTEC only, 15s timeout). Used for tooltip lookups.
|
||||
* @response 200 - { asset_id, ip, confirmed, unconfirmed, declined, candidate, update_token }
|
||||
* @response 400 - { error: string } — missing IP
|
||||
* @response 404 - { error: string } — IP not found in CARD
|
||||
* @response 504 - { error: string, timeout: true } — CARD lookup timed out
|
||||
* @response 503 - { error: string } — CARD not configured
|
||||
*/
|
||||
router.get('/owner-lookup/:ip', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
@@ -522,8 +525,19 @@ function createCardApiRouter() {
|
||||
return res.status(400).json({ error: 'IP address is required.' });
|
||||
}
|
||||
|
||||
// Use quick mode (CTEC only, 15s timeout) for tooltip lookups
|
||||
const quick = req.query.quick === '1';
|
||||
|
||||
// Resolve to full asset ID
|
||||
const assetId = await resolveAssetId(ip.trim());
|
||||
let assetId;
|
||||
try {
|
||||
assetId = await resolveAssetId(ip.trim(), quick ? { quick: true } : undefined);
|
||||
} catch (err) {
|
||||
if (err.message === 'CARD_TIMEOUT') {
|
||||
return res.status(504).json({ error: 'CARD lookup timed out', timeout: true });
|
||||
}
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
if (!assetId) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` });
|
||||
}
|
||||
@@ -552,6 +566,208 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/confirm
|
||||
*
|
||||
* Directly confirm ownership of a CARD asset (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD confirm.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} teamName - Team to confirm ownership for (required)
|
||||
* @body {string} [comment] - Optional comment
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { teamName, comment } = req.body || {};
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
|
||||
// Resolve bare IP to full CARD asset ID
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (confirmResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
|
||||
return res.status(confirmResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/decline
|
||||
*
|
||||
* Directly decline ownership of a CARD asset (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD decline.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} teamName - Team to decline ownership for (required)
|
||||
* @body {string} [comment] - Optional comment
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { teamName, comment } = req.body || {};
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (declineResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
|
||||
return res.status(declineResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/redirect
|
||||
*
|
||||
* Directly redirect a CARD asset between teams (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD redirect.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} fromTeam - Current owning team (required)
|
||||
* @body {string} toTeam - Target team (required)
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { fromTeam, toTeam } = req.body || {};
|
||||
|
||||
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
|
||||
return res.status(400).json({ error: 'fromTeam is required.' });
|
||||
}
|
||||
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
|
||||
return res.status(400).json({ error: 'toTeam is required.' });
|
||||
}
|
||||
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
|
||||
|
||||
if (redirectResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect_direct', entityType: 'card_asset', entityId: assetId, details: { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
|
||||
return res.status(redirectResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /enrich-batch
|
||||
*
|
||||
|
||||
@@ -318,16 +318,17 @@ function createIvantiTodoQueueRouter() {
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue/:id/redirect
|
||||
*
|
||||
* Redirects a completed queue item to a different workflow by creating a new
|
||||
* pending queue item with the same finding data but a new workflow type/vendor.
|
||||
* Redirects a queue item to a different workflow type. If the item is pending,
|
||||
* updates workflow_type in place. If the item is complete, creates a new pending
|
||||
* queue item with the same finding data but a new workflow type/vendor.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {string} id — Queue item ID of the completed item (URL parameter)
|
||||
* @param {string} id — Queue item ID (URL parameter)
|
||||
* @body {Object}
|
||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
||||
* @returns {Object} The newly created queue item with parsed `cves` array
|
||||
* @error 400 Invalid input or item not in complete status
|
||||
* @returns {Object} The updated or newly created queue item with parsed `cves` array
|
||||
* @error 400 Invalid input
|
||||
* @error 404 Queue item not found
|
||||
* @error 500 Internal server error
|
||||
*/
|
||||
@@ -358,10 +359,38 @@ function createIvantiTodoQueueRouter() {
|
||||
if (!original) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (original.status !== 'complete') {
|
||||
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
|
||||
|
||||
// If the item is still pending, update workflow_type in place (no duplication)
|
||||
if (original.status === 'pending') {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE ivanti_todo_queue SET workflow_type = $1, vendor = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND user_id = $4 RETURNING *`,
|
||||
[workflow_type, vendorVal, id, req.user.id]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'queue_item_redirected',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(original.id),
|
||||
details: {
|
||||
original_workflow_type: original.workflow_type,
|
||||
target_workflow_type: workflow_type,
|
||||
method: 'in_place_update',
|
||||
vendor: vendorVal,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
const result = {
|
||||
...rows[0],
|
||||
cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
|
||||
};
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
// If the item is complete, create a new pending item (legacy behavior)
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||
@@ -379,6 +408,7 @@ function createIvantiTodoQueueRouter() {
|
||||
details: {
|
||||
original_workflow_type: original.workflow_type,
|
||||
target_workflow_type: workflow_type,
|
||||
method: 'new_item_from_complete',
|
||||
new_item_id: rows[0].id,
|
||||
vendor: vendorVal,
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ const logAudit = require('./helpers/auditLog');
|
||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||
const createArcherTemplatesRouter = require('./routes/archerTemplates');
|
||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||
@@ -201,6 +202,9 @@ app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload));
|
||||
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
||||
app.use('/api/archer-tickets', createArcherTicketsRouter());
|
||||
|
||||
// Archer template library routes (editor/admin for create/update/delete/clone, all authenticated for view)
|
||||
app.use('/api/archer-templates', createArcherTemplatesRouter());
|
||||
|
||||
// Ivanti / RiskSense workflow routes (all authenticated users)
|
||||
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter());
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import JiraPage from './components/pages/JiraPage';
|
||||
import AdminPage from './components/pages/AdminPage';
|
||||
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
||||
import ArcherPage from './components/pages/ArcherPage';
|
||||
import ArcherTemplatePage from './components/pages/ArcherTemplatePage';
|
||||
import FeedbackModal from './components/FeedbackModal';
|
||||
import NotificationBell from './components/NotificationBell';
|
||||
import './App.css';
|
||||
@@ -199,7 +200,7 @@ export default function App() {
|
||||
const [cveDocuments, setCveDocuments] = useState({});
|
||||
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
||||
const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin']);
|
||||
const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin', 'archer-templates']);
|
||||
const [currentPage, setCurrentPageRaw] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('cve-dashboard-page');
|
||||
@@ -1105,6 +1106,7 @@ export default function App() {
|
||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||
{currentPage === 'exports' && <ExportsPage />}
|
||||
{currentPage === 'jira' && <JiraPage />}
|
||||
{currentPage === 'archer-templates' && <ArcherTemplatePage />}
|
||||
{currentPage === 'admin' && isAdmin() && <AdminPage />}
|
||||
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
|
||||
|
||||
|
||||
376
frontend/src/components/CardDetailModal.js
Normal file
376
frontend/src/components/CardDetailModal.js
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* CardDetailModal — Full CARD ownership detail view
|
||||
*
|
||||
* Opens from the CARD tooltip "Actions" button on the reporting page.
|
||||
* Shows the full ownership record and allows confirm/decline/redirect
|
||||
* directly against the CARD API (no queue item required).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, XCircle, ArrowRightLeft } from 'lucide-react';
|
||||
|
||||
// ⚠️ CONVENTION: Prefer using REACT_APP_API_BASE without an absolute URL fallback — other components use relative paths via the env var (e.g. '' default) rather than hardcoding http://localhost:3001/api
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const OVERLAY = {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)',
|
||||
zIndex: 10200, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
};
|
||||
const MODAL = {
|
||||
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
||||
borderRadius: '1rem', border: '1px solid rgba(124, 58, 237, 0.25)',
|
||||
width: '90vw', maxWidth: '580px', maxHeight: '85vh', overflow: 'auto',
|
||||
padding: '1.5rem', position: 'relative',
|
||||
};
|
||||
const SECTION = {
|
||||
background: 'rgba(15, 23, 42, 0.6)', border: '1px solid rgba(51, 65, 85, 0.5)',
|
||||
borderRadius: '0.5rem', padding: '0.75rem', marginBottom: '0.75rem',
|
||||
};
|
||||
const LABEL = { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' };
|
||||
const VALUE = { fontSize: '0.75rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" };
|
||||
const TEAM_BADGE = (color) => ({
|
||||
display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: '0.25rem',
|
||||
fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
background: `${color}15`, border: `1px solid ${color}40`, color,
|
||||
});
|
||||
const INPUT = {
|
||||
width: '100%', boxSizing: 'border-box', background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(51, 65, 85, 0.6)', borderRadius: '0.375rem',
|
||||
color: '#E2E8F0', padding: '0.5rem 0.75rem', fontSize: '0.75rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", outline: 'none',
|
||||
};
|
||||
const BTN = {
|
||||
padding: '0.5rem 1.25rem', borderRadius: '0.375rem', border: 'none',
|
||||
fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer', transition: 'all 0.12s',
|
||||
};
|
||||
|
||||
export default function CardDetailModal({ isOpen, onClose, ip, ownerData: initialOwnerData, cardTeams }) {
|
||||
const [ownerData, setOwnerData] = useState(initialOwnerData || null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [action, setAction] = useState('confirm');
|
||||
const [teamName, setTeamName] = useState('');
|
||||
const [fromTeam, setFromTeam] = useState('');
|
||||
const [toTeam, setToTeam] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [execError, setExecError] = useState(null);
|
||||
const [execSuccess, setExecSuccess] = useState(null);
|
||||
|
||||
// Fetch owner data if not provided or refresh on open
|
||||
useEffect(() => {
|
||||
if (!isOpen || !ip) return;
|
||||
|
||||
// If we already have data from the tooltip cache, use it
|
||||
if (initialOwnerData && !initialOwnerData.notFound && !initialOwnerData.error) {
|
||||
setOwnerData(initialOwnerData);
|
||||
// Pre-fill team fields
|
||||
if (initialOwnerData.confirmed) {
|
||||
setTeamName(initialOwnerData.confirmed.name || '');
|
||||
setFromTeam(initialOwnerData.confirmed.name || '');
|
||||
} else if (initialOwnerData.unconfirmed) {
|
||||
setTeamName(initialOwnerData.unconfirmed.name || '');
|
||||
setFromTeam(initialOwnerData.unconfirmed.name || '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch fresh
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}`, { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); });
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
setOwnerData(data);
|
||||
if (data.confirmed) {
|
||||
setTeamName(data.confirmed.name || '');
|
||||
setFromTeam(data.confirmed.name || '');
|
||||
} else if (data.unconfirmed) {
|
||||
setTeamName(data.unconfirmed.name || '');
|
||||
setFromTeam(data.unconfirmed.name || '');
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [isOpen, ip, initialOwnerData]);
|
||||
|
||||
// Reset state on close
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setExecError(null);
|
||||
setExecSuccess(null);
|
||||
setComment('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!ownerData?.asset_id) return;
|
||||
setExecuting(true);
|
||||
setExecError(null);
|
||||
setExecSuccess(null);
|
||||
|
||||
try {
|
||||
let url, body;
|
||||
const assetId = ownerData.asset_id;
|
||||
|
||||
if (action === 'confirm') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/confirm`;
|
||||
body = { teamName: teamName.trim(), comment: comment.trim() };
|
||||
} else if (action === 'decline') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/decline`;
|
||||
body = { teamName: teamName.trim(), comment: comment.trim() };
|
||||
} else if (action === 'redirect') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/redirect`;
|
||||
body = { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() };
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setExecError(data.error || data.message || `${action} failed.`);
|
||||
} else {
|
||||
setExecSuccess(`${action.charAt(0).toUpperCase() + action.slice(1)} successful.`);
|
||||
}
|
||||
} catch (err) {
|
||||
setExecError(err.message || 'Network error.');
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
}, [ownerData, action, teamName, fromTeam, toTeam, comment]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canExecute = () => {
|
||||
if (action === 'confirm' || action === 'decline') return teamName.trim().length > 0;
|
||||
if (action === 'redirect') return fromTeam.trim().length > 0 && toTeam.trim().length > 0;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={OVERLAY} onClick={onClose}>
|
||||
<div style={MODAL} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '0.95rem' }}>CARD Asset Details</h3>
|
||||
<div style={{ fontSize: '0.72rem', color: '#0EA5E9', fontFamily: "'JetBrains Mono', monospace", marginTop: '0.2rem' }}>
|
||||
{ip}
|
||||
</div>
|
||||
{ownerData?.asset_id && (
|
||||
<div style={{ fontSize: '0.65rem', color: '#7C3AED', fontFamily: 'monospace', marginTop: '0.1rem' }}>
|
||||
{ownerData.asset_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Loader style={{ width: '20px', height: '20px', color: '#7C3AED', animation: 'spin 1s linear infinite' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading CARD data...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{ ...SECTION, borderColor: 'rgba(239, 68, 68, 0.4)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444' }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#FCA5A5' }}>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner data */}
|
||||
{ownerData && !loading && (
|
||||
<>
|
||||
{/* Ownership section */}
|
||||
<div style={SECTION}>
|
||||
<div style={LABEL}>Ownership</div>
|
||||
<div style={{ display: 'grid', gap: '0.5rem', marginTop: '0.3rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Confirmed:</span>
|
||||
{ownerData.confirmed ? (
|
||||
<>
|
||||
<span style={TEAM_BADGE('#10B981')}>{ownerData.confirmed.name}</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
|
||||
(score: {ownerData.confirmed.score}, {ownerData.confirmed.datasource || 'n/a'})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ ...VALUE, color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Unconfirmed:</span>
|
||||
{ownerData.unconfirmed ? (
|
||||
<>
|
||||
<span style={TEAM_BADGE('#F59E0B')}>{ownerData.unconfirmed.name}</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
|
||||
(score: {ownerData.unconfirmed.score})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ ...VALUE, color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
{ownerData.candidate && ownerData.candidate.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Candidates:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
{ownerData.candidate.map((c, i) => (
|
||||
<span key={i} style={TEAM_BADGE('#94A3B8')}>{c.name} ({c.score})</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ownerData.declined && ownerData.declined.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Declined:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
{ownerData.declined.map((d, i) => (
|
||||
<span key={i} style={TEAM_BADGE('#EF4444')}>{d.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action section */}
|
||||
<div style={{ ...SECTION, borderColor: 'rgba(124, 58, 237, 0.3)' }}>
|
||||
<div style={LABEL}>Action</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.4rem', marginBottom: '0.75rem' }}>
|
||||
{['confirm', 'decline', 'redirect'].map(a => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setAction(a)}
|
||||
style={{
|
||||
...BTN,
|
||||
padding: '0.35rem 0.75rem',
|
||||
background: action === a ? (a === 'confirm' ? 'rgba(16,185,129,0.15)' : a === 'decline' ? 'rgba(239,68,68,0.15)' : 'rgba(14,165,233,0.15)') : 'transparent',
|
||||
border: `1px solid ${action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#334155'}`,
|
||||
color: action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#64748B',
|
||||
}}
|
||||
>
|
||||
{a === 'confirm' && <CheckCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a === 'decline' && <XCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a === 'redirect' && <ArrowRightLeft style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a.charAt(0).toUpperCase() + a.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action-specific fields */}
|
||||
{(action === 'confirm' || action === 'decline') && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>Team</label>
|
||||
<select style={INPUT} value={teamName} onChange={e => setTeamName(e.target.value)}>
|
||||
<option value="">Select team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate, score: {c.score})</option>
|
||||
))}
|
||||
<option disabled>───────────</option>
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>Comment (optional)</label>
|
||||
<input style={INPUT} value={comment} onChange={e => setComment(e.target.value)} placeholder="Optional comment..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'redirect' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>From Team</label>
|
||||
<select style={INPUT} value={fromTeam} onChange={e => setFromTeam(e.target.value)}>
|
||||
<option value="">Select from team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate)</option>
|
||||
))}
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>To Team</label>
|
||||
<select style={INPUT} value={toTeam} onChange={e => setToTeam(e.target.value)}>
|
||||
<option value="">Select to team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate)</option>
|
||||
))}
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution error */}
|
||||
{execError && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
||||
<AlertCircle style={{ width: '13px', height: '13px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#FCA5A5' }}>{execError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{execSuccess && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
||||
<CheckCircle style={{ width: '13px', height: '13px', color: '#10B981', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#6EE7B7' }}>{execSuccess}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<button onClick={onClose} style={{ ...BTN, background: '#334155', color: '#E2E8F0' }}>Close</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={!canExecute() || executing || !!execSuccess}
|
||||
style={{
|
||||
...BTN,
|
||||
background: canExecute() && !executing && !execSuccess ? '#7C3AED' : '#1E293B',
|
||||
color: canExecute() && !executing && !execSuccess ? '#fff' : '#475569',
|
||||
cursor: canExecute() && !executing && !execSuccess ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{executing ? 'Executing...' : `Execute ${action.charAt(0).toUpperCase() + action.slice(1)}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
333
frontend/src/components/CardOwnerTooltip.js
Normal file
333
frontend/src/components/CardOwnerTooltip.js
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* CardOwnerTooltip — CARD ownership hover tooltip
|
||||
*
|
||||
* Shows CARD asset ownership data (confirmed/unconfirmed/candidate teams)
|
||||
* when hovering over an IP address in the findings table.
|
||||
* Interactive — stays open when you hover into it, includes an Actions button.
|
||||
* Follows the same portal + positioning pattern as CveTooltip.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Loader, AlertCircle, ExternalLink } from 'lucide-react';
|
||||
|
||||
// ⚠️ CONVENTION: Use relative API path from REACT_APP_API_BASE only (no absolute URL fallback).
|
||||
// Other components use: const API_BASE = process.env.REACT_APP_API_BASE || '/api';
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const TOOLTIP_GAP = 8;
|
||||
const ARROW_SIZE = 6;
|
||||
const BORDER_COLOR = '#7C3AED'; // purple to match CARD branding
|
||||
|
||||
function calcPosition(anchorRect, tooltipHeight, viewportHeight) {
|
||||
const spaceAbove = anchorRect.top;
|
||||
const spaceBelow = viewportHeight - anchorRect.bottom;
|
||||
const needed = tooltipHeight + TOOLTIP_GAP + ARROW_SIZE;
|
||||
|
||||
const placeAbove = spaceAbove >= needed || spaceAbove >= spaceBelow;
|
||||
|
||||
let top;
|
||||
if (placeAbove) {
|
||||
top = anchorRect.top - tooltipHeight - TOOLTIP_GAP - ARROW_SIZE;
|
||||
if (top < 0) top = 0;
|
||||
} else {
|
||||
top = anchorRect.bottom + TOOLTIP_GAP + ARROW_SIZE;
|
||||
if (top + tooltipHeight > viewportHeight) top = viewportHeight - tooltipHeight;
|
||||
}
|
||||
|
||||
const left = anchorRect.left + anchorRect.width / 2;
|
||||
|
||||
return { top, left, placeAbove };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main exported component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function CardOwnerTooltip({ ip, anchorRect, cache, cardConfigured, onAction, onMouseEnter, onMouseLeave }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ip) {
|
||||
setData(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cardConfigured) {
|
||||
setError('CARD not configured');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (cache.current.has(ip)) {
|
||||
const cached = cache.current.get(ip);
|
||||
if (cached.error) {
|
||||
setError(cached.error);
|
||||
setData(null);
|
||||
} else {
|
||||
setData(cached);
|
||||
setError(null);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setData(null);
|
||||
setError(null);
|
||||
|
||||
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}?quick=1`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 404) {
|
||||
const result = { notFound: true };
|
||||
cache.current.set(ip, result);
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (res.status === 504) {
|
||||
// Timeout — don't cache, can be retried
|
||||
setError('CARD lookup timed out — try again');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (res.status === 502) {
|
||||
// CARD unreachable — don't cache
|
||||
setError('CARD unavailable');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) return res.json().then(d => { throw new Error(d.error || `HTTP ${res.status}`); });
|
||||
return res.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!payload) return; // 404 already handled
|
||||
cache.current.set(ip, payload);
|
||||
setData(payload);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return;
|
||||
cache.current.set(ip, { error: err.message });
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [ip, cache, cardConfigured]);
|
||||
|
||||
if (!ip || !anchorRect) return null;
|
||||
if (!loading && !data && !error) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<TooltipBody
|
||||
data={data}
|
||||
loading={loading}
|
||||
error={error}
|
||||
anchorRect={anchorRect}
|
||||
ip={ip}
|
||||
onAction={onAction}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TooltipBody — inner component for measurement + rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
function TooltipBody({ data, loading, error, anchorRect, ip, onAction, onMouseEnter, onMouseLeave }) {
|
||||
const tooltipRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, placeAbove: true });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!tooltipRef.current || !anchorRect) return;
|
||||
const rect = tooltipRef.current.getBoundingClientRect();
|
||||
const vp = window.innerHeight;
|
||||
setPos(calcPosition(anchorRect, rect.height, vp));
|
||||
}, [anchorRect, data, loading, error]);
|
||||
|
||||
const handleAction = useCallback(() => {
|
||||
if (onAction && ip) {
|
||||
onAction(ip, data);
|
||||
}
|
||||
}, [onAction, ip, data]);
|
||||
|
||||
const tooltipStyle = {
|
||||
position: 'fixed',
|
||||
zIndex: 99999,
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: 340,
|
||||
minWidth: 220,
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
|
||||
border: `1.5px solid ${BORDER_COLOR}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${BORDER_COLOR}33`,
|
||||
pointerEvents: 'auto',
|
||||
transition: 'opacity 0.15s ease',
|
||||
};
|
||||
|
||||
const arrowStyle = {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: `${ARROW_SIZE}px solid transparent`,
|
||||
borderRight: `${ARROW_SIZE}px solid transparent`,
|
||||
...(pos.placeAbove
|
||||
? { bottom: -ARROW_SIZE, borderTop: `${ARROW_SIZE}px solid ${BORDER_COLOR}`, borderBottom: 'none' }
|
||||
: { top: -ARROW_SIZE, borderBottom: `${ARROW_SIZE}px solid ${BORDER_COLOR}`, borderTop: 'none' }),
|
||||
};
|
||||
|
||||
const LABEL = { fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.15rem' };
|
||||
const BADGE = (color) => ({
|
||||
display: 'inline-block', padding: '0.12rem 0.45rem', borderRadius: '0.2rem',
|
||||
fontSize: '0.68rem', fontWeight: '600', fontFamily: "'JetBrains Mono', monospace",
|
||||
background: `${color}18`, border: `1px solid ${color}50`, color,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={tooltipRef} style={tooltipStyle} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
|
||||
<div style={arrowStyle} />
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.6rem', color: '#7C3AED', fontFamily: 'monospace', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
CARD
|
||||
</span>
|
||||
<span style={{ fontSize: '0.72rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace", fontWeight: '600' }}>
|
||||
{ip}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0.5rem 0' }}>
|
||||
<Loader style={{ width: 16, height: 16, color: '#7C3AED', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !loading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<AlertCircle style={{ width: 12, height: 12, color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.68rem', color: '#FCA5A5' }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not found */}
|
||||
{data && data.notFound && !loading && (
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B', fontFamily: 'monospace' }}>
|
||||
Not found in CARD
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner data */}
|
||||
{data && !data.notFound && !data.error && !loading && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{/* Asset ID */}
|
||||
{data.asset_id && (
|
||||
<div>
|
||||
<div style={LABEL}>Asset ID</div>
|
||||
<div style={{ fontSize: '0.68rem', color: '#A78BFA', fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{data.asset_id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmed */}
|
||||
<div>
|
||||
<div style={LABEL}>Confirmed Owner</div>
|
||||
{data.confirmed ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={BADGE('#10B981')}>{data.confirmed.name}</span>
|
||||
{data.confirmed.score != null && (
|
||||
<span style={{ fontSize: '0.58rem', color: '#64748B' }}>score: {data.confirmed.score}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unconfirmed */}
|
||||
{data.unconfirmed && (
|
||||
<div>
|
||||
<div style={LABEL}>Unconfirmed</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={BADGE('#F59E0B')}>{data.unconfirmed.name}</span>
|
||||
{data.unconfirmed.score != null && (
|
||||
<span style={{ fontSize: '0.58rem', color: '#64748B' }}>score: {data.unconfirmed.score}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Candidates */}
|
||||
{data.candidate && data.candidate.length > 0 && (
|
||||
<div>
|
||||
<div style={LABEL}>Candidates</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{data.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map((c, i) => (
|
||||
<span key={i} style={BADGE('#94A3B8')}>{c.name} ({c.score})</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Declined */}
|
||||
{data.declined && data.declined.length > 0 && (
|
||||
<div>
|
||||
<div style={LABEL}>Declined</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{data.declined.map((d, i) => (
|
||||
<span key={i} style={BADGE('#EF4444')}>{d.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions button */}
|
||||
{onAction && (
|
||||
<div style={{ marginTop: '0.4rem', paddingTop: '0.4rem', borderTop: '1px solid rgba(124, 58, 237, 0.2)' }}>
|
||||
<button
|
||||
onClick={handleAction}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
|
||||
padding: '0.3rem 0.65rem',
|
||||
background: 'rgba(124, 58, 237, 0.12)',
|
||||
border: '1px solid rgba(124, 58, 237, 0.4)',
|
||||
borderRadius: '0.3rem',
|
||||
color: '#A78BFA',
|
||||
fontSize: '0.65rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.25)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.6)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.12)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.4)'; }}
|
||||
>
|
||||
<ExternalLink style={{ width: 11, height: 11 }} />
|
||||
Actions
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2 } from 'lucide-react';
|
||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2, Layers } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||
{ id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' },
|
||||
{ id: 'archer-templates', label: 'Template Mgr', icon: Layers, color: '#F472B6', description: 'Archer template library' },
|
||||
];
|
||||
|
||||
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
||||
|
||||
522
frontend/src/components/TemplateFormModal.js
Normal file
522
frontend/src/components/TemplateFormModal.js
Normal file
@@ -0,0 +1,522 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
621
frontend/src/components/TemplateSelector.js
Normal file
621
frontend/src/components/TemplateSelector.js
Normal file
@@ -0,0 +1,621 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Search, ChevronDown, Loader, FileText, Clipboard, Check, Copy } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section field mapping — ordered: static first, then semi-static
|
||||
// ---------------------------------------------------------------------------
|
||||
const SECTIONS = [
|
||||
// Static sections
|
||||
{ key: 'environment_overview', label: 'Environment Overview' },
|
||||
{ key: 'segmentation', label: 'Segmentation' },
|
||||
{ key: 'mitigating_controls', label: 'Mitigating Controls' },
|
||||
// Semi-static sections
|
||||
{ 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 — dark theme tactical intelligence aesthetic
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLES = {
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
},
|
||||
label: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: '#00d4ff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em',
|
||||
marginBottom: '0.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
},
|
||||
searchWrapper: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
searchIcon: {
|
||||
position: 'absolute',
|
||||
left: '0.75rem',
|
||||
color: '#64748b',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
padding: '0.625rem 2.25rem 0.625rem 2.25rem',
|
||||
background: 'rgba(15, 23, 42, 0.9)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||||
borderRadius: '8px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '0.82rem',
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||
},
|
||||
inputFocused: {
|
||||
borderColor: 'rgba(0, 212, 255, 0.5)',
|
||||
boxShadow: '0 0 12px rgba(0, 212, 255, 0.1)',
|
||||
},
|
||||
chevron: {
|
||||
position: 'absolute',
|
||||
right: '0.75rem',
|
||||
color: '#64748b',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
},
|
||||
chevronOpen: {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
dropdown: {
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: '4px',
|
||||
background: 'linear-gradient(135deg, rgba(22, 33, 62, 0.98), rgba(15, 23, 42, 0.99))',
|
||||
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||||
borderRadius: '8px',
|
||||
maxHeight: '240px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 50,
|
||||
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.6)',
|
||||
},
|
||||
dropdownItem: {
|
||||
padding: '0.6rem 0.875rem',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
borderBottom: '1px solid rgba(100, 116, 139, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
dropdownItemHover: {
|
||||
background: 'rgba(0, 212, 255, 0.08)',
|
||||
},
|
||||
dropdownItemSelected: {
|
||||
background: 'rgba(0, 212, 255, 0.12)',
|
||||
color: '#00d4ff',
|
||||
},
|
||||
loadingState: {
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
color: '#64748b',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
},
|
||||
emptyState: {
|
||||
padding: '1rem',
|
||||
textAlign: 'center',
|
||||
color: '#64748b',
|
||||
fontSize: '0.8rem',
|
||||
fontStyle: 'italic',
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
},
|
||||
selectedDisplay: {
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: 'rgba(0, 212, 255, 0.06)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.15)',
|
||||
borderRadius: '6px',
|
||||
color: '#00d4ff',
|
||||
fontSize: '0.78rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
},
|
||||
// Section panel styles
|
||||
sectionPanel: {
|
||||
marginTop: '1rem',
|
||||
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)',
|
||||
borderRadius: '10px',
|
||||
padding: '1rem',
|
||||
},
|
||||
sectionPanelHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.75rem',
|
||||
paddingBottom: '0.5rem',
|
||||
borderBottom: '1px solid rgba(100, 116, 139, 0.2)',
|
||||
},
|
||||
sectionPanelTitle: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: '#00d4ff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
},
|
||||
copyAllButton: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
padding: '0.35rem 0.65rem',
|
||||
background: 'rgba(0, 212, 255, 0.1)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.3)',
|
||||
borderRadius: '6px',
|
||||
color: '#00d4ff',
|
||||
fontSize: '0.72rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s, border-color 0.2s',
|
||||
},
|
||||
copyAllButtonHover: {
|
||||
background: 'rgba(0, 212, 255, 0.18)',
|
||||
borderColor: 'rgba(0, 212, 255, 0.5)',
|
||||
},
|
||||
copyAllButtonCopied: {
|
||||
background: 'rgba(34, 197, 94, 0.15)',
|
||||
borderColor: 'rgba(34, 197, 94, 0.4)',
|
||||
color: '#22c55e',
|
||||
},
|
||||
sectionBlock: {
|
||||
marginBottom: '0.75rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
background: 'rgba(15, 23, 42, 0.5)',
|
||||
border: '1px solid rgba(100, 116, 139, 0.15)',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
sectionBlockHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.35rem',
|
||||
},
|
||||
sectionLabel: {
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#94a3b8',
|
||||
letterSpacing: '0.02em',
|
||||
},
|
||||
sectionContent: {
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
fontSize: '0.78rem',
|
||||
color: '#e0e0e0',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: '120px',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
sectionEmpty: {
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
fontSize: '0.78rem',
|
||||
color: '#64748b',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
copyButton: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
background: 'rgba(100, 116, 139, 0.15)',
|
||||
border: '1px solid rgba(100, 116, 139, 0.25)',
|
||||
borderRadius: '4px',
|
||||
color: '#94a3b8',
|
||||
fontSize: '0.68rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s, color 0.2s, border-color 0.2s',
|
||||
},
|
||||
copyButtonHover: {
|
||||
background: 'rgba(0, 212, 255, 0.1)',
|
||||
borderColor: 'rgba(0, 212, 255, 0.3)',
|
||||
color: '#00d4ff',
|
||||
},
|
||||
copyButtonCopied: {
|
||||
background: 'rgba(34, 197, 94, 0.12)',
|
||||
borderColor: 'rgba(34, 197, 94, 0.3)',
|
||||
color: '#22c55e',
|
||||
},
|
||||
copyButtonDisabled: {
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* TemplateSelector — searchable dropdown for selecting Archer templates.
|
||||
*
|
||||
* Props:
|
||||
* onSelect {function} — optional callback invoked with the full template object when a selection is made
|
||||
*/
|
||||
export default function TemplateSelector({ onSelect }) {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
|
||||
// Copy state: per-section copied confirmation + copy all
|
||||
const [copiedSections, setCopiedSections] = useState({});
|
||||
const [copyAllCopied, setCopyAllCopied] = useState(false);
|
||||
const [copyAllHovered, setCopyAllHovered] = useState(false);
|
||||
const [hoveredCopyButton, setHoveredCopyButton] = useState(null);
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Fetch all templates on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function fetchTemplates() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`${API_BASE}/archer-templates`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch templates (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!cancelled) {
|
||||
setTemplates(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err.message);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchTemplates();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Client-side filter — case-insensitive substring match on vendor, platform, or model
|
||||
const filteredTemplates = useCallback(() => {
|
||||
if (!searchText.trim()) return templates;
|
||||
const query = searchText.toLowerCase().trim();
|
||||
return templates.filter(t =>
|
||||
t.vendor.toLowerCase().includes(query) ||
|
||||
t.platform.toLowerCase().includes(query) ||
|
||||
t.model.toLowerCase().includes(query)
|
||||
);
|
||||
}, [templates, searchText])();
|
||||
|
||||
// Handle template selection
|
||||
const handleSelect = (template) => {
|
||||
setSelectedTemplate(template);
|
||||
setSearchText(`${template.vendor} / ${template.platform} / ${template.model}`);
|
||||
setIsOpen(false);
|
||||
setCopiedSections({});
|
||||
setCopyAllCopied(false);
|
||||
if (onSelect) {
|
||||
onSelect(template);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input change
|
||||
const handleInputChange = (e) => {
|
||||
setSearchText(e.target.value);
|
||||
setSelectedTemplate(null);
|
||||
setIsOpen(true);
|
||||
setHoveredIndex(-1);
|
||||
};
|
||||
|
||||
// Handle input focus
|
||||
const handleInputFocus = () => {
|
||||
setInputFocused(true);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
// Handle input blur
|
||||
const handleInputBlur = () => {
|
||||
setInputFocused(false);
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = (e) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
setIsOpen(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setHoveredIndex(prev =>
|
||||
prev < filteredTemplates.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHoveredIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filteredTemplates.length - 1
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (hoveredIndex >= 0 && hoveredIndex < filteredTemplates.length) {
|
||||
handleSelect(filteredTemplates[hoveredIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Copy a single section to clipboard
|
||||
const handleCopySection = async (sectionKey, content) => {
|
||||
if (!content) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopiedSections(prev => ({ ...prev, [sectionKey]: true }));
|
||||
setTimeout(() => {
|
||||
setCopiedSections(prev => ({ ...prev, [sectionKey]: false }));
|
||||
}, 2000);
|
||||
} catch (_err) {
|
||||
// Clipboard API failed — silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Copy All: concatenate non-empty sections with headers
|
||||
const handleCopyAll = async () => {
|
||||
if (!selectedTemplate) return;
|
||||
const parts = [];
|
||||
for (const section of SECTIONS) {
|
||||
const content = selectedTemplate[section.key];
|
||||
if (content && content.trim()) {
|
||||
parts.push(`${section.label}\n${content}`);
|
||||
}
|
||||
}
|
||||
const combined = parts.join('\n\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(combined);
|
||||
setCopyAllCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopyAllCopied(false);
|
||||
}, 2000);
|
||||
} catch (_err) {
|
||||
// Clipboard API failed — silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Check if there are any non-empty sections
|
||||
const hasNonEmptySections = selectedTemplate && SECTIONS.some(s => {
|
||||
const val = selectedTemplate[s.key];
|
||||
return val && val.trim();
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={STYLES.container} ref={containerRef}>
|
||||
{/* Label */}
|
||||
<div style={STYLES.label}>
|
||||
<FileText size={12} />
|
||||
Template Selector
|
||||
</div>
|
||||
|
||||
{/* Search input with dropdown */}
|
||||
<div style={STYLES.searchWrapper}>
|
||||
<Search size={14} style={STYLES.searchIcon} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={loading ? 'Loading templates...' : 'Search by vendor, platform, or model...'}
|
||||
value={searchText}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={loading}
|
||||
style={{
|
||||
...STYLES.input,
|
||||
...(inputFocused ? STYLES.inputFocused : {}),
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}
|
||||
aria-label="Search templates"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
...STYLES.chevron,
|
||||
...(isOpen ? STYLES.chevronOpen : {}),
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdown list */}
|
||||
{isOpen && (
|
||||
<div style={STYLES.dropdown} role="listbox" aria-label="Template list">
|
||||
{loading ? (
|
||||
<div style={STYLES.loadingState}>
|
||||
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
Loading templates...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ ...STYLES.emptyState, color: '#ef4444' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div style={STYLES.emptyState}>
|
||||
{searchText.trim()
|
||||
? 'No templates match your search'
|
||||
: 'No templates available'}
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map((template, index) => {
|
||||
const isSelected = selectedTemplate?.id === template.id;
|
||||
const isHovered = hoveredIndex === index;
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
style={{
|
||||
...STYLES.dropdownItem,
|
||||
...(isHovered ? STYLES.dropdownItemHover : {}),
|
||||
...(isSelected ? STYLES.dropdownItemSelected : {}),
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(-1)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // Prevent input blur before click registers
|
||||
handleSelect(template);
|
||||
}}
|
||||
>
|
||||
<FileText size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
|
||||
{template.vendor} / {template.platform} / {template.model}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section display panel — shown when a template is selected */}
|
||||
{selectedTemplate && (
|
||||
<div style={STYLES.sectionPanel}>
|
||||
{/* Panel header with Copy All button */}
|
||||
<div style={STYLES.sectionPanelHeader}>
|
||||
<span style={STYLES.sectionPanelTitle}>Template Sections</span>
|
||||
{hasNonEmptySections && (
|
||||
<button
|
||||
onClick={handleCopyAll}
|
||||
onMouseEnter={() => setCopyAllHovered(true)}
|
||||
onMouseLeave={() => setCopyAllHovered(false)}
|
||||
style={{
|
||||
...STYLES.copyAllButton,
|
||||
...(copyAllCopied ? STYLES.copyAllButtonCopied : {}),
|
||||
...(!copyAllCopied && copyAllHovered ? STYLES.copyAllButtonHover : {}),
|
||||
}}
|
||||
aria-label="Copy all sections"
|
||||
>
|
||||
{copyAllCopied ? (
|
||||
<>
|
||||
<Check size={11} />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={11} />
|
||||
Copy All
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section blocks */}
|
||||
{SECTIONS.map((section) => {
|
||||
const content = selectedTemplate[section.key];
|
||||
const isEmpty = !content || !content.trim();
|
||||
const isCopied = copiedSections[section.key];
|
||||
const isButtonHovered = hoveredCopyButton === section.key;
|
||||
|
||||
return (
|
||||
<div key={section.key} style={STYLES.sectionBlock}>
|
||||
<div style={STYLES.sectionBlockHeader}>
|
||||
<span style={STYLES.sectionLabel}>{section.label}</span>
|
||||
<button
|
||||
onClick={() => handleCopySection(section.key, content)}
|
||||
disabled={isEmpty}
|
||||
onMouseEnter={() => setHoveredCopyButton(section.key)}
|
||||
onMouseLeave={() => setHoveredCopyButton(null)}
|
||||
style={{
|
||||
...STYLES.copyButton,
|
||||
...(isEmpty ? STYLES.copyButtonDisabled : {}),
|
||||
...(isCopied ? STYLES.copyButtonCopied : {}),
|
||||
...(!isEmpty && !isCopied && isButtonHovered ? STYLES.copyButtonHover : {}),
|
||||
}}
|
||||
aria-label={`Copy ${section.label}`}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check size={10} />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard size={10} />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isEmpty ? (
|
||||
<div style={STYLES.sectionEmpty}>No content stored</div>
|
||||
) : (
|
||||
<div style={STYLES.sectionContent}>{content}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
569
frontend/src/components/pages/ArcherTemplatePage.js
Normal file
569
frontend/src/components/pages/ArcherTemplatePage.js
Normal file
@@ -0,0 +1,569 @@
|
||||
// ArcherTemplatePage.js
|
||||
// Full-page Template Manager — browse, create, edit, clone, and delete
|
||||
// Archer Risk Acceptance templates organized by Vendor > Platform > Model.
|
||||
// Write operations require editor/admin role (Standard_User or Admin group).
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FileText, Plus, Edit, Copy, Trash2, ChevronDown, ChevronRight,
|
||||
Loader, AlertCircle, RefreshCw, Eye, EyeOff, Clipboard, Check,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import TemplateFormModal from '../TemplateFormModal';
|
||||
import DeleteConfirmModal from '../DeleteConfirmModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// Section field mapping — ordered: 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 — dark theme tactical intelligence aesthetic
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLES = {
|
||||
page: {
|
||||
minHeight: '60vh',
|
||||
},
|
||||
card: {
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98))',
|
||||
border: '1px solid rgba(0, 212, 255, 0.15)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
header: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: '#00d4ff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
btn: {
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(0, 212, 255, 0.3)',
|
||||
background: 'rgba(0, 212, 255, 0.1)',
|
||||
color: '#7DD3FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
btnDanger: {
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
color: '#FCA5A5',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
btnSmall: {
|
||||
padding: '0.35rem 0.65rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(0, 212, 255, 0.25)',
|
||||
background: 'rgba(0, 212, 255, 0.08)',
|
||||
color: '#7DD3FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
vendorGroup: {
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
vendorHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
background: 'rgba(0, 212, 255, 0.05)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.12)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
},
|
||||
vendorLabel: {
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 700,
|
||||
color: '#e0e0e0',
|
||||
},
|
||||
vendorCount: {
|
||||
fontSize: '0.7rem',
|
||||
color: '#64748B',
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
platformSubgroup: {
|
||||
marginLeft: '1.25rem',
|
||||
marginTop: '0.5rem',
|
||||
paddingLeft: '0.75rem',
|
||||
borderLeft: '2px solid rgba(0, 212, 255, 0.1)',
|
||||
},
|
||||
platformLabel: {
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
color: '#94A3B8',
|
||||
marginBottom: '0.35rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
},
|
||||
templateRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.45rem 0.6rem',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '0.25rem',
|
||||
transition: 'background 0.15s',
|
||||
},
|
||||
templateModel: {
|
||||
fontSize: '0.82rem',
|
||||
color: '#e0e0e0',
|
||||
fontWeight: 500,
|
||||
},
|
||||
templateActions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
},
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
padding: '3rem 1rem',
|
||||
color: '#64748B',
|
||||
fontSize: '0.9rem',
|
||||
},
|
||||
errorBanner: {
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
color: '#FCA5A5',
|
||||
fontSize: '0.82rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
loadingState: {
|
||||
textAlign: 'center',
|
||||
padding: '3rem 1rem',
|
||||
color: '#64748B',
|
||||
fontSize: '0.85rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Group templates by vendor, then by platform within each vendor.
|
||||
* Returns: [ { vendor, platforms: [ { platform, templates: [...] } ] } ]
|
||||
*/
|
||||
function groupTemplates(templates) {
|
||||
const vendorMap = {};
|
||||
for (const t of templates) {
|
||||
if (!vendorMap[t.vendor]) vendorMap[t.vendor] = {};
|
||||
if (!vendorMap[t.vendor][t.platform]) vendorMap[t.vendor][t.platform] = [];
|
||||
vendorMap[t.vendor][t.platform].push(t);
|
||||
}
|
||||
|
||||
const vendors = Object.keys(vendorMap).sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
||||
);
|
||||
|
||||
return vendors.map(vendor => {
|
||||
const platforms = Object.keys(vendorMap[vendor]).sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
||||
);
|
||||
return {
|
||||
vendor,
|
||||
platforms: platforms.map(platform => ({
|
||||
platform,
|
||||
templates: vendorMap[vendor][platform].sort((a, b) =>
|
||||
a.model.localeCompare(b.model, undefined, { sensitivity: 'base' })
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ArcherTemplatePage
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ArcherTemplatePage() {
|
||||
const { canWrite } = useAuth();
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [expandedVendors, setExpandedVendors] = useState({});
|
||||
|
||||
// Modal state for create/edit/clone
|
||||
const [modalState, setModalState] = useState({ open: false, mode: 'create', template: null });
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
// View panel state — which template ID is expanded for viewing
|
||||
const [viewExpandedId, setViewExpandedId] = useState(null);
|
||||
// Copy state for view panel
|
||||
const [copiedSections, setCopiedSections] = useState({});
|
||||
const [copyAllCopied, setCopyAllCopied] = useState(false);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fetch templates
|
||||
// -------------------------------------------------------------------------
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/archer-templates`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Failed to fetch templates (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setTemplates(data);
|
||||
// Expand all vendors by default on initial load
|
||||
const expanded = {};
|
||||
const grouped = groupTemplates(data);
|
||||
for (const g of grouped) {
|
||||
expanded[g.vendor] = true;
|
||||
}
|
||||
setExpandedVendors(expanded);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, [fetchTemplates]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Vendor toggle
|
||||
// -------------------------------------------------------------------------
|
||||
const toggleVendor = (vendor) => {
|
||||
setExpandedVendors(prev => ({ ...prev, [vendor]: !prev[vendor] }));
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// View panel toggle and copy handlers
|
||||
// -------------------------------------------------------------------------
|
||||
const toggleView = (templateId) => {
|
||||
setViewExpandedId(prev => prev === templateId ? null : templateId);
|
||||
setCopiedSections({});
|
||||
setCopyAllCopied(false);
|
||||
};
|
||||
|
||||
const handleCopySection = async (sectionKey, content) => {
|
||||
if (!content) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopiedSections(prev => ({ ...prev, [sectionKey]: true }));
|
||||
setTimeout(() => {
|
||||
setCopiedSections(prev => ({ ...prev, [sectionKey]: false }));
|
||||
}, 2000);
|
||||
} catch (_err) { /* clipboard failed */ }
|
||||
};
|
||||
|
||||
const handleCopyAll = async (template) => {
|
||||
const parts = [];
|
||||
for (const section of SECTIONS) {
|
||||
const content = template[section.key];
|
||||
if (content && content.trim()) {
|
||||
parts.push(`${section.label}\n${content}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(parts.join('\n\n'));
|
||||
setCopyAllCopied(true);
|
||||
setTimeout(() => setCopyAllCopied(false), 2000);
|
||||
} catch (_err) { /* clipboard failed */ }
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Grouped data
|
||||
// -------------------------------------------------------------------------
|
||||
const grouped = groupTemplates(templates);
|
||||
const totalCount = templates.length;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Render
|
||||
// -------------------------------------------------------------------------
|
||||
return (
|
||||
<div style={STYLES.page}>
|
||||
<div style={STYLES.card}>
|
||||
{/* Header */}
|
||||
<div style={STYLES.header}>
|
||||
<FileText size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '0.4rem' }} />
|
||||
Archer Template Library
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={STYLES.toolbar}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ color: '#94A3B8', fontSize: '0.8rem' }}>
|
||||
{totalCount} template{totalCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<button
|
||||
style={STYLES.btn}
|
||||
onClick={fetchTemplates}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canWrite() && (
|
||||
<button
|
||||
style={STYLES.btn}
|
||||
onClick={() => setModalState({ open: true, mode: 'create', template: null })}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create Template
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div style={STYLES.errorBanner}>
|
||||
<AlertCircle size={14} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div style={STYLES.loadingState}>
|
||||
<Loader size={24} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
<span>Loading templates...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !error && templates.length === 0 && (
|
||||
<div style={STYLES.emptyState}>
|
||||
<FileText size={32} style={{ marginBottom: '0.75rem', opacity: 0.4 }} />
|
||||
<div>No templates found</div>
|
||||
{canWrite() && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.8rem', color: '#475569' }}>
|
||||
Click "Create Template" to add your first template.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template list grouped by vendor > platform */}
|
||||
{!loading && grouped.map(({ vendor, platforms }) => (
|
||||
<div key={vendor} style={STYLES.vendorGroup}>
|
||||
{/* Vendor header — collapsible */}
|
||||
<div
|
||||
style={STYLES.vendorHeader}
|
||||
onClick={() => toggleVendor(vendor)}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.1)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.05)'; }}
|
||||
>
|
||||
{expandedVendors[vendor]
|
||||
? <ChevronDown size={14} style={{ color: '#00d4ff' }} />
|
||||
: <ChevronRight size={14} style={{ color: '#64748B' }} />
|
||||
}
|
||||
<span style={STYLES.vendorLabel}>{vendor}</span>
|
||||
<span style={STYLES.vendorCount}>
|
||||
{platforms.reduce((sum, p) => sum + p.templates.length, 0)} template{platforms.reduce((sum, p) => sum + p.templates.length, 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Platform subgroups */}
|
||||
{expandedVendors[vendor] && platforms.map(({ platform, templates: platTemplates }) => (
|
||||
<div key={platform} style={STYLES.platformSubgroup}>
|
||||
<div style={STYLES.platformLabel}>{platform}</div>
|
||||
{platTemplates.map(template => (
|
||||
<div key={template.id}>
|
||||
<div
|
||||
style={STYLES.templateRow}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span
|
||||
style={{ ...STYLES.templateModel, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
onClick={() => toggleView(template.id)}
|
||||
title="View template sections"
|
||||
>
|
||||
{viewExpandedId === template.id
|
||||
? <EyeOff size={13} style={{ color: '#00d4ff' }} />
|
||||
: <Eye size={13} style={{ color: '#64748B' }} />
|
||||
}
|
||||
{template.model}
|
||||
</span>
|
||||
{canWrite() && (
|
||||
<div style={STYLES.templateActions}>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'edit', template })}
|
||||
title="Edit template"
|
||||
>
|
||||
<Edit size={12} />
|
||||
</button>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'clone', template })}
|
||||
title="Clone template"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
style={STYLES.btnDanger}
|
||||
onClick={() => setDeleteTarget(template)}
|
||||
title="Delete template"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Expandable view panel */}
|
||||
{viewExpandedId === template.id && (
|
||||
<div style={{
|
||||
margin: '0.25rem 0 0.75rem 1.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.12)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
{/* Copy All button */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
|
||||
<button
|
||||
onClick={() => handleCopyAll(template)}
|
||||
style={{
|
||||
padding: '0.3rem 0.6rem',
|
||||
borderRadius: '5px',
|
||||
border: copyAllCopied ? '1px solid rgba(34, 197, 94, 0.4)' : '1px solid rgba(0, 212, 255, 0.3)',
|
||||
background: copyAllCopied ? 'rgba(34, 197, 94, 0.12)' : 'rgba(0, 212, 255, 0.08)',
|
||||
color: copyAllCopied ? '#22c55e' : '#00d4ff',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
}}
|
||||
>
|
||||
{copyAllCopied ? <><Check size={11} /> Copied!</> : <><Clipboard size={11} /> Copy All</>}
|
||||
</button>
|
||||
</div>
|
||||
{/* Section blocks */}
|
||||
{SECTIONS.map(section => {
|
||||
const content = template[section.key];
|
||||
const isEmpty = !content || !content.trim();
|
||||
const isCopied = copiedSections[section.key];
|
||||
return (
|
||||
<div key={section.key} style={{
|
||||
marginBottom: '0.5rem',
|
||||
padding: '0.5rem 0.6rem',
|
||||
background: 'rgba(30, 41, 59, 0.5)',
|
||||
border: '1px solid rgba(100, 116, 139, 0.12)',
|
||||
borderRadius: '6px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.25rem' }}>
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{section.label}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopySection(section.key, content)}
|
||||
disabled={isEmpty}
|
||||
style={{
|
||||
padding: '0.2rem 0.4rem',
|
||||
borderRadius: '4px',
|
||||
border: isCopied ? '1px solid rgba(34, 197, 94, 0.3)' : '1px solid rgba(100, 116, 139, 0.25)',
|
||||
background: isCopied ? 'rgba(34, 197, 94, 0.1)' : 'rgba(100, 116, 139, 0.1)',
|
||||
color: isCopied ? '#22c55e' : '#94a3b8',
|
||||
fontSize: '0.65rem',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
opacity: isEmpty ? 0.4 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.2rem',
|
||||
}}
|
||||
>
|
||||
{isCopied ? <><Check size={9} /> Copied!</> : <><Clipboard size={9} /> Copy</>}
|
||||
</button>
|
||||
</div>
|
||||
{isEmpty ? (
|
||||
<div style={{ fontSize: '0.78rem', color: '#475569', fontStyle: 'italic' }}>No content stored</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '0.78rem', color: '#e0e0e0', lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: '150px', overflowY: 'auto' }}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template form modal (create/edit/clone) */}
|
||||
{modalState.open && (
|
||||
<TemplateFormModal
|
||||
mode={modalState.mode}
|
||||
template={modalState.template}
|
||||
onClose={() => setModalState({ open: false, mode: 'create', template: null })}
|
||||
onSuccess={fetchTemplates}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<DeleteConfirmModal
|
||||
template={deleteTarget}
|
||||
onConfirm={() => {
|
||||
setDeleteTarget(null);
|
||||
fetchTemplates();
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,26 @@ const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
const PURPLE = '#A78BFA';
|
||||
|
||||
// Natural sort comparator for metric IDs like "2.3.6i", "5.2.6", "10.1.1".
|
||||
// Splits on "." and compares each segment numerically (trailing letters after digits sort after pure numbers).
|
||||
function compareMetricIds(a, b) {
|
||||
const partsA = a.split('.');
|
||||
const partsB = b.split('.');
|
||||
const len = Math.max(partsA.length, partsB.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const segA = partsA[i] || '';
|
||||
const segB = partsB[i] || '';
|
||||
const numA = parseFloat(segA) || 0;
|
||||
const numB = parseFloat(segB) || 0;
|
||||
if (numA !== numB) return numA - numB;
|
||||
// Same numeric prefix — compare the suffix (e.g. "6i" vs "6")
|
||||
const suffA = segA.replace(/^[\d.]+/, '');
|
||||
const suffB = segB.replace(/^[\d.]+/, '');
|
||||
if (suffA !== suffB) return suffA.localeCompare(suffB);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -106,7 +126,8 @@ function MetricBreakdownPanel({ metrics }) {
|
||||
if (!metrics || metrics.length === 0) return null;
|
||||
|
||||
// Only show metrics with non_compliant > 0
|
||||
const ncMetrics = metrics.filter(m => m.non_compliant > 0);
|
||||
const ncMetrics = metrics.filter(m => m.non_compliant > 0)
|
||||
.sort((a, b) => compareMetricIds(a.metric_id, b.metric_id));
|
||||
if (ncMetrics.length === 0) return null;
|
||||
|
||||
const TOP_COUNT = 8;
|
||||
@@ -628,9 +649,10 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
|
||||
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
|
||||
|
||||
// Filter metrics by team if a team filter is active
|
||||
const displayMetrics = teamFilter
|
||||
const displayMetrics = (teamFilter
|
||||
? metrics.filter(m => m.sub_teams && m.sub_teams.some(st => st.team === teamFilter))
|
||||
: metrics;
|
||||
: metrics
|
||||
).slice().sort((a, b) => compareMetricIds(a.metric_id, b.metric_id));
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -1282,7 +1304,7 @@ function MetricSelector({ onMetricSelect, selectedMetric }) {
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
{metrics.map(m => (
|
||||
{metrics.slice().sort((a, b) => compareMetricIds(a.metric_id, b.metric_id)).map(m => (
|
||||
<option key={m.metric_id} value={m.metric_id}>
|
||||
{m.metric_id} — {m.device_count} device{m.device_count !== 1 ? 's' : ''}
|
||||
</option>
|
||||
|
||||
@@ -44,7 +44,7 @@ function MetricChip({ metricId, category, status }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onNavigate }) {
|
||||
export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onMetadataSaved, onNavigate }) {
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -137,6 +137,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
setRemediationPlanEdited(false);
|
||||
// Re-fetch to get updated history
|
||||
await fetchDetail();
|
||||
if (onMetadataSaved) onMetadataSaved();
|
||||
} catch (err) {
|
||||
setMetaError(err.message);
|
||||
} finally {
|
||||
@@ -295,33 +296,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Resolved metrics */}
|
||||
{resolvedMetrics.length > 0 && (
|
||||
<Section title="Resolved Metrics" muted>
|
||||
{resolvedMetrics.map(m => (
|
||||
<MetricRow key={m.metric_id} metric={m} resolved />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Upload history summary */}
|
||||
{activeMetrics.length > 0 && (
|
||||
<Section title="History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
||||
{activeMetrics.map(m => (
|
||||
<div key={m.metric_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<MetricChip metricId={m.metric_id} category={m.category} />
|
||||
<div style={{ fontSize: '0.72rem', color: '#64748B', fontFamily: 'monospace', textAlign: 'right' }}>
|
||||
<span style={{ color: m.seen_count > 2 ? '#F59E0B' : '#94A3B8' }}>
|
||||
{m.seen_count}× seen
|
||||
</span>
|
||||
{m.first_seen && <span style={{ marginLeft: '0.5rem' }}>since {m.first_seen}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Metric Selector for Metadata Editing */}
|
||||
{/* Metric Selector for Metadata Editing — placed right after Failing Metrics per issue #21 */}
|
||||
{activeMetrics.length > 0 && (
|
||||
<Section title="Apply To Metrics" icon={<Shield style={{ width: '14px', height: '14px' }} />}>
|
||||
{activeMetrics.length > 1 && (() => {
|
||||
@@ -433,6 +408,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: 'monospace',
|
||||
outline: 'none',
|
||||
colorScheme: 'dark',
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
|
||||
/>
|
||||
@@ -520,6 +496,32 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Resolved metrics */}
|
||||
{resolvedMetrics.length > 0 && (
|
||||
<Section title="Resolved Metrics" muted>
|
||||
{resolvedMetrics.map(m => (
|
||||
<MetricRow key={m.metric_id} metric={m} resolved />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Upload history summary */}
|
||||
{activeMetrics.length > 0 && (
|
||||
<Section title="History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
||||
{activeMetrics.map(m => (
|
||||
<div key={m.metric_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<MetricChip metricId={m.metric_id} category={m.category} />
|
||||
<div style={{ fontSize: '0.72rem', color: '#64748B', fontFamily: 'monospace', textAlign: 'right' }}>
|
||||
<span style={{ color: m.seen_count > 2 ? '#F59E0B' : '#94A3B8' }}>
|
||||
{m.seen_count}× seen
|
||||
</span>
|
||||
{m.first_seen && <span style={{ marginLeft: '0.5rem' }}>since {m.first_seen}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Change History */}
|
||||
{detail.history && detail.history.length > 0 && (
|
||||
<Section title="Change History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
||||
|
||||
@@ -697,6 +697,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
hostname={selectedHost}
|
||||
onClose={() => setSelectedHost(null)}
|
||||
onNoteAdded={refresh}
|
||||
onMetadataSaved={refresh}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet } from 'lucide-react';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet, Layers } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import IvantiCountsChart from './IvantiCountsChart';
|
||||
import AnomalyBanner from './AnomalyBanner';
|
||||
import CveTooltip from '../CveTooltip';
|
||||
import CardOwnerTooltip from '../CardOwnerTooltip';
|
||||
import CardDetailModal from '../CardDetailModal';
|
||||
import RedirectModal from '../RedirectModal';
|
||||
import AtlasBadge from '../AtlasBadge';
|
||||
import LoaderModal from '../LoaderModal';
|
||||
@@ -1186,7 +1188,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render a single table cell by column key
|
||||
// ---------------------------------------------------------------------------
|
||||
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) {
|
||||
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, onIpMouseEnter, onIpMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) {
|
||||
switch (colKey) {
|
||||
case 'findingId':
|
||||
return (
|
||||
@@ -1259,7 +1261,11 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
|
||||
);
|
||||
case 'ipAddress':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
<td
|
||||
style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', cursor: finding.ipAddress ? 'help' : 'default' }}
|
||||
onMouseEnter={onIpMouseEnter && finding.ipAddress ? (e) => onIpMouseEnter(finding.ipAddress, e) : undefined}
|
||||
onMouseLeave={onIpMouseLeave || undefined}
|
||||
>
|
||||
{finding.ipAddress || '—'}
|
||||
</td>
|
||||
);
|
||||
@@ -2002,13 +2008,13 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Redirect button — completed items only */}
|
||||
{canWrite && done && (
|
||||
{/* Redirect button — available on all items */}
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => setRedirectItem(item)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: done ? '#334155' : '#475569', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = done ? '#334155' : '#475569'}
|
||||
title="Redirect to another workflow"
|
||||
>
|
||||
<CornerUpRight style={{ width: '13px', height: '13px' }} />
|
||||
@@ -5832,6 +5838,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const tooltipCacheRef = useRef(new Map());
|
||||
const hoverTimerRef = useRef(null);
|
||||
|
||||
// CARD owner tooltip state & refs
|
||||
const [cardTooltipIp, setCardTooltipIp] = useState(null);
|
||||
const [cardTooltipAnchorRect, setCardTooltipAnchorRect] = useState(null);
|
||||
const cardTooltipCacheRef = useRef(new Map());
|
||||
const cardHoverTimerRef = useRef(null);
|
||||
|
||||
// Atlas action plan state
|
||||
const [metricsTab, setMetricsTab] = useState('ivanti');
|
||||
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
|
||||
@@ -5852,6 +5864,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const [cardConfigured, setCardConfigured] = useState(false);
|
||||
const [cardTeams, setCardTeams] = useState([]);
|
||||
|
||||
// Group-by-host toggle state
|
||||
const [groupByHost, setGroupByHost] = useState(false);
|
||||
const [expandedHosts, setExpandedHosts] = useState(new Set());
|
||||
|
||||
const updateColumns = useCallback((newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
saveColumnOrder(newOrder);
|
||||
@@ -5920,6 +5936,49 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
setTooltipAnchorRect(null);
|
||||
}, []);
|
||||
|
||||
// CARD owner tooltip hover handlers (with hover bridge — stays open when hovering tooltip)
|
||||
const handleIpMouseEnter = useCallback((ip, e) => {
|
||||
if (!ip) return;
|
||||
clearTimeout(cardHoverTimerRef.current);
|
||||
cardHoverTimerRef.current = setTimeout(() => {
|
||||
setCardTooltipIp(ip);
|
||||
setCardTooltipAnchorRect(e.target.getBoundingClientRect());
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
const handleIpMouseLeave = useCallback(() => {
|
||||
clearTimeout(cardHoverTimerRef.current);
|
||||
// Delay hiding to allow mouse to move into tooltip
|
||||
cardHoverTimerRef.current = setTimeout(() => {
|
||||
setCardTooltipIp(null);
|
||||
setCardTooltipAnchorRect(null);
|
||||
}, 150);
|
||||
}, []);
|
||||
|
||||
const handleCardTooltipEnter = useCallback(() => {
|
||||
// Mouse entered tooltip — cancel the hide timer
|
||||
clearTimeout(cardHoverTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const handleCardTooltipLeave = useCallback(() => {
|
||||
// Mouse left tooltip — hide it
|
||||
clearTimeout(cardHoverTimerRef.current);
|
||||
setCardTooltipIp(null);
|
||||
setCardTooltipAnchorRect(null);
|
||||
}, []);
|
||||
|
||||
// CARD action — open CardActionModal from tooltip
|
||||
const [cardActionIp, setCardActionIp] = useState(null);
|
||||
const [cardActionData, setCardActionData] = useState(null);
|
||||
|
||||
const handleCardAction = useCallback((ip, data) => {
|
||||
setCardActionIp(ip);
|
||||
setCardActionData(data);
|
||||
// Close the tooltip
|
||||
setCardTooltipIp(null);
|
||||
setCardTooltipAnchorRect(null);
|
||||
}, []);
|
||||
|
||||
const applyState = (data) => {
|
||||
setTotal(data.total ?? 0);
|
||||
setFindings(data.findings || []);
|
||||
@@ -6000,6 +6059,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
|
||||
// CARD API — fetch status and teams (session-level caching)
|
||||
const cardTeamsFetchedRef = useRef(false);
|
||||
const cardTeamsRetryRef = useRef(0);
|
||||
const fetchCardStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' });
|
||||
@@ -6007,19 +6067,30 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const data = await res.json();
|
||||
setCardConfigured(data.configured === true);
|
||||
if (data.configured && !cardTeamsFetchedRef.current) {
|
||||
cardTeamsFetchedRef.current = true;
|
||||
const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' });
|
||||
if (teamsRes.ok) {
|
||||
const teamsData = await teamsRes.json();
|
||||
const teams = Array.isArray(teamsData)
|
||||
? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort()
|
||||
: [];
|
||||
setCardTeams(teams);
|
||||
if (teams.length > 0) {
|
||||
setCardTeams(teams);
|
||||
cardTeamsFetchedRef.current = true;
|
||||
}
|
||||
} else if (cardTeamsRetryRef.current < 3) {
|
||||
// Retry silently after a delay (CARD teams endpoint can be slow)
|
||||
cardTeamsRetryRef.current += 1;
|
||||
setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[card-api] Failed to fetch CARD status:', err.message);
|
||||
// Retry on network error too
|
||||
if (cardTeamsRetryRef.current < 3) {
|
||||
cardTeamsRetryRef.current += 1;
|
||||
setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -6165,6 +6236,67 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
return sort.dir === 'asc' ? cmp : -cmp;
|
||||
}), [filtered, sort]);
|
||||
|
||||
// Grouped view — aggregate findings by hostName + ipAddress
|
||||
const groupedByHost = useMemo(() => {
|
||||
if (!groupByHost) return { groups: [], singles: [] };
|
||||
const map = new Map();
|
||||
sorted.forEach(f => {
|
||||
const hostKey = `${(f.overrides?.hostName || f.hostName || '').toLowerCase()}||${(f.ipAddress || '').toLowerCase()}`;
|
||||
if (!map.has(hostKey)) {
|
||||
map.set(hostKey, {
|
||||
hostKey,
|
||||
hostName: f.overrides?.hostName || f.hostName || '',
|
||||
ipAddress: f.ipAddress || '',
|
||||
findings: [],
|
||||
highestSeverity: 0,
|
||||
highestVrrGroup: '',
|
||||
cveSet: new Set(),
|
||||
});
|
||||
}
|
||||
const group = map.get(hostKey);
|
||||
group.findings.push(f);
|
||||
if (f.severity > group.highestSeverity) {
|
||||
group.highestSeverity = f.severity;
|
||||
group.highestVrrGroup = f.vrrGroup || '';
|
||||
}
|
||||
(f.cves || []).forEach(c => group.cveSet.add(c));
|
||||
});
|
||||
// Separate: groups with 2+ findings vs singles that stay flat
|
||||
const groups = [];
|
||||
const singles = [];
|
||||
for (const g of map.values()) {
|
||||
if (g.findings.length > 1) groups.push(g);
|
||||
else singles.push(g.findings[0]);
|
||||
}
|
||||
groups.sort((a, b) => b.highestSeverity - a.highestSeverity);
|
||||
return { groups, singles };
|
||||
}, [sorted, groupByHost]);
|
||||
|
||||
// Combined render order for grouped mode: grouped hosts first, then singles
|
||||
const groupedRenderList = useMemo(() => {
|
||||
if (!groupByHost) return [];
|
||||
const list = [];
|
||||
groupedByHost.groups.forEach(g => list.push({ type: 'group', group: g }));
|
||||
groupedByHost.singles.forEach(f => list.push({ type: 'single', finding: f }));
|
||||
return list;
|
||||
}, [groupByHost, groupedByHost]);
|
||||
|
||||
const toggleHostExpand = useCallback((hostKey) => {
|
||||
setExpandedHosts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(hostKey)) next.delete(hostKey); else next.add(hostKey);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandAllHosts = useCallback(() => {
|
||||
setExpandedHosts(new Set(groupedByHost.groups.map(g => g.hostKey)));
|
||||
}, [groupedByHost]);
|
||||
|
||||
const collapseAllHosts = useCallback(() => {
|
||||
setExpandedHosts(new Set());
|
||||
}, []);
|
||||
|
||||
// Select/deselect all visible rows
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
const allVisibleIds = sorted.map(f => String(f.id));
|
||||
@@ -6908,6 +7040,24 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setGroupByHost(g => !g); setExpandedHosts(new Set()); }}
|
||||
title={groupByHost ? 'Switch to flat view' : 'Group findings by host'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: groupByHost ? 'rgba(139,92,246,0.15)' : 'rgba(139,92,246,0.06)',
|
||||
border: `1px solid rgba(139,92,246,${groupByHost ? '0.5' : '0.2'})`,
|
||||
borderRadius: '0.375rem',
|
||||
color: groupByHost ? '#A78BFA' : '#7C3AED',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
<Layers style={{ width: '13px', height: '13px' }} />
|
||||
{groupByHost ? 'Grouped' : 'Group'}
|
||||
</button>
|
||||
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
||||
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
|
||||
<button
|
||||
@@ -7136,6 +7286,178 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupByHost ? (
|
||||
/* ---- Grouped-by-host view ---- */
|
||||
<>
|
||||
{groupedRenderList.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={visibleCols.length + 3} style={{ padding: '0.4rem 0.75rem', background: 'rgba(139,92,246,0.04)', borderBottom: '1px solid rgba(139,92,246,0.15)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#A78BFA', fontWeight: '600' }}>
|
||||
{groupedByHost.groups.length} grouped host{groupedByHost.groups.length !== 1 ? 's' : ''} · {groupedByHost.singles.length} single{groupedByHost.singles.length !== 1 ? 's' : ''} · {sorted.length} total
|
||||
</span>
|
||||
<button onClick={expandAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>expand all</button>
|
||||
<button onClick={collapseAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>collapse all</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{groupedRenderList.map((item, itemIdx) => {
|
||||
if (item.type === 'single') {
|
||||
// Render single-finding hosts as normal flat rows
|
||||
const finding = item.finding;
|
||||
const isSelected = selectedIds.has(finding.id);
|
||||
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (itemIdx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)');
|
||||
const queued = isQueued(finding.id);
|
||||
return (
|
||||
<tr
|
||||
key={finding.id}
|
||||
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
|
||||
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
|
||||
>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }} onClick={() => toggleRowSelection(finding.id)}>
|
||||
{selectedRowIds.has(String(finding.id))
|
||||
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
|
||||
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||||
<button onClick={() => hideRow(finding.id)} title="Hide this row" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }} onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}>
|
||||
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { if (queued) return; setSelectedIds((prev) => { const next = new Set(prev); if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id); return next; }); setLastClickedId(finding.id); }}>
|
||||
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
|
||||
</td>
|
||||
{visibleCols.map((col) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
// Render grouped host header + expandable sub-rows
|
||||
const group = item.group;
|
||||
const isExpanded = expandedHosts.has(group.hostKey);
|
||||
const sc = severityColor(group.highestVrrGroup);
|
||||
return (
|
||||
<React.Fragment key={group.hostKey}>
|
||||
{/* Host group header — uses same columns as regular rows */}
|
||||
<tr
|
||||
onClick={() => toggleHostExpand(group.hostKey)}
|
||||
style={{
|
||||
borderBottom: '1px solid rgba(139,92,246,0.15)',
|
||||
background: isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(139,92,246,0.08)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)'; }}
|
||||
>
|
||||
{/* Expand/collapse icon in first fixed column */}
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||||
{isExpanded
|
||||
? <ChevronDown style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
|
||||
: <ChevronRight style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
|
||||
}
|
||||
</td>
|
||||
{/* Empty cells for hide + checkbox columns */}
|
||||
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
|
||||
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
|
||||
{/* Render each column cell — show host-level summary data in the matching column positions */}
|
||||
{visibleCols.map((col) => {
|
||||
switch (col.key) {
|
||||
case 'findingId':
|
||||
return (
|
||||
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', padding: '0.15rem 0.5rem', borderRadius: '0.25rem', background: 'rgba(139,92,246,0.12)', border: '1px solid rgba(139,92,246,0.3)', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700', color: '#A78BFA' }}>
|
||||
{group.findings.length} findings
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case 'severity':
|
||||
return (
|
||||
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', padding: '0.2rem 0.45rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}>
|
||||
{group.highestSeverity.toFixed(2)}
|
||||
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{group.highestVrrGroup}</span>
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case 'hostName':
|
||||
return (
|
||||
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
|
||||
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600' }}>
|
||||
{group.hostName || '—'}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case 'ipAddress':
|
||||
return (
|
||||
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ color: '#0EA5E9', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
{group.ipAddress || '—'}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case 'cves':
|
||||
return (
|
||||
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B' }}>
|
||||
{group.cveSet.size} CVE{group.cveSet.size !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
return <td key={col.key} style={{ padding: '0.45rem 0.75rem' }} />;
|
||||
}
|
||||
})}
|
||||
</tr>
|
||||
{/* Expanded sub-rows — individual findings */}
|
||||
{isExpanded && group.findings.map((finding, idx) => {
|
||||
const isSelected = selectedIds.has(finding.id);
|
||||
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(20,30,50,0.5)' : 'rgba(15,24,42,0.5)');
|
||||
const queued = isQueued(finding.id);
|
||||
return (
|
||||
<tr
|
||||
key={finding.id}
|
||||
style={{ borderBottom: '1px solid rgba(255,255,255,0.03)', background: rowBg, borderLeft: '3px solid rgba(139,92,246,0.25)' }}
|
||||
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
|
||||
>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }} onClick={() => toggleRowSelection(finding.id)}>
|
||||
{selectedRowIds.has(String(finding.id))
|
||||
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
|
||||
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||||
<button onClick={() => hideRow(finding.id)} title="Hide this row" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }} onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}>
|
||||
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { if (queued) return; setSelectedIds((prev) => { const next = new Set(prev); if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id); return next; }); setLastClickedId(finding.id); }}>
|
||||
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
|
||||
</td>
|
||||
{visibleCols.map((col) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{groupedRenderList.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={visibleCols.length + 3} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* ---- Flat view (default) ---- */
|
||||
<>
|
||||
{sorted.map((finding, idx) => {
|
||||
const isSelected = selectedIds.has(finding.id);
|
||||
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)');
|
||||
@@ -7218,7 +7540,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
/>
|
||||
</td>
|
||||
{visibleCols.map((col) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
@@ -7230,6 +7552,8 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -7273,10 +7597,18 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
onDeleteMany={deleteQueueItems}
|
||||
onClearCompleted={clearCompleted}
|
||||
onCreateFpWorkflow={handleCreateFpWorkflow}
|
||||
onRedirectComplete={(newItem) => {
|
||||
setQueueItems((prev) => [...prev, newItem].sort((a, b) =>
|
||||
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
|
||||
));
|
||||
onRedirectComplete={(updatedItem) => {
|
||||
setQueueItems((prev) => {
|
||||
// If item already exists (in-place update), replace it
|
||||
const exists = prev.some(i => i.id === updatedItem.id);
|
||||
if (exists) {
|
||||
return prev.map(i => i.id === updatedItem.id ? updatedItem : i);
|
||||
}
|
||||
// Otherwise it's a new item (redirect from completed), add it
|
||||
return [...prev, updatedItem].sort((a, b) =>
|
||||
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
|
||||
);
|
||||
});
|
||||
}}
|
||||
canWrite={canWrite}
|
||||
fpSubmissions={fpSubmissionsFiltered}
|
||||
@@ -7304,6 +7636,22 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
anchorRect={tooltipAnchorRect}
|
||||
cache={tooltipCacheRef}
|
||||
/>
|
||||
<CardOwnerTooltip
|
||||
ip={cardTooltipIp}
|
||||
anchorRect={cardTooltipAnchorRect}
|
||||
cache={cardTooltipCacheRef}
|
||||
cardConfigured={cardConfigured}
|
||||
onAction={handleCardAction}
|
||||
onMouseEnter={handleCardTooltipEnter}
|
||||
onMouseLeave={handleCardTooltipLeave}
|
||||
/>
|
||||
<CardDetailModal
|
||||
isOpen={!!cardActionIp}
|
||||
onClose={() => { setCardActionIp(null); setCardActionData(null); }}
|
||||
ip={cardActionIp}
|
||||
ownerData={cardActionData}
|
||||
cardTeams={cardTeams}
|
||||
/>
|
||||
{atlasPanelOpen && atlasSelectedHostId && (
|
||||
<AtlasSlideOutPanel
|
||||
hostId={atlasSelectedHostId}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Bug Condition Exploration Property Test:
|
||||
* Compliance List Stale After Sidebar Edit
|
||||
*
|
||||
* Spec: .kiro/specs/compliance-list-stale-after-sidebar-edit/ (bugfix)
|
||||
* Issue: #23 — "[Bug] Update Res Date/Remed Plan in list after updating in sidebar"
|
||||
* http://steam-gitlab.charterlab.com/steam/cve-dashboard/-/issues/23
|
||||
*
|
||||
* BUG CONDITION (bugfix.md Current Behavior 1.1–1.3):
|
||||
* isBugCondition(input) = a successful PATCH /api/compliance/items/:hostname/metadata
|
||||
* occurred from the sidebar (ComplianceDetailPanel). Under this condition the parent
|
||||
* CompliancePage list is never re-fetched: handleSaveMetadata() does not notify the
|
||||
* parent (1.1), onClose only clears selectedHost (1.2), and the row keeps the stale
|
||||
* Resolution Date / Remediation Plan held in the parent `devices` state (1.3).
|
||||
*
|
||||
* THIS TEST IS EXPECTED TO FAIL ON UNFIXED CODE — failure confirms the bug.
|
||||
* The property encodes the expected behavior (bugfix.md 2.1, 2.2): for any saved
|
||||
* metadata value, after a successful sidebar save the parent list row for that
|
||||
* hostname displays the saved value WITHOUT a manual filter/team/tab change or a
|
||||
* manual refresh click, because a parent refresh callback re-issues fetchDevices.
|
||||
* On unfixed code no parent callback fires, so the list GET is never re-issued after
|
||||
* the PATCH and the row keeps showing the stale value ("—").
|
||||
*
|
||||
* Mirrors the tagging convention of
|
||||
* backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js
|
||||
*
|
||||
* **Validates: Requirements 1.1, 1.2, 1.3, 2.1, 2.2**
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
|
||||
// --- Mocks (hoisted by babel-jest above the CompliancePage import) ---
|
||||
|
||||
// Stub auth so CompliancePage renders the STEAM team with write access.
|
||||
jest.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
canWrite: () => true,
|
||||
isAdmin: () => false,
|
||||
getAvailableTeams: () => ['STEAM'],
|
||||
adminScope: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The historical charts panel is irrelevant to the list-staleness bug and pulls
|
||||
// in recharts; stub it so the test stays focused and fast across property runs.
|
||||
jest.mock('../ComplianceChartsPanel', () => () => null);
|
||||
|
||||
import CompliancePage from '../CompliancePage';
|
||||
|
||||
const HOSTNAME = 'HOST-001';
|
||||
const METRIC_ID = '2.3.6i';
|
||||
|
||||
// --- Fixtures --------------------------------------------------------------
|
||||
|
||||
function jsonResponse(body, ok = true) {
|
||||
return Promise.resolve({ ok, json: async () => body });
|
||||
}
|
||||
|
||||
function makeListDevice({ resolution_date, remediation_plan }) {
|
||||
return {
|
||||
hostname: HOSTNAME,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'Switch',
|
||||
failing_metrics: [{ metric_id: METRIC_ID, category: 'Vulnerability Management' }],
|
||||
resolution_date,
|
||||
remediation_plan,
|
||||
seen_count: 3,
|
||||
has_notes: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail() {
|
||||
return {
|
||||
hostname: HOSTNAME,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'Switch',
|
||||
team: 'STEAM',
|
||||
metrics: [
|
||||
{
|
||||
metric_id: METRIC_ID,
|
||||
category: 'Vulnerability Management',
|
||||
status: 'active',
|
||||
metric_desc: 'Outbound encryption required on all endpoints',
|
||||
resolution_date: null,
|
||||
remediation_plan: null,
|
||||
seen_count: 3,
|
||||
first_seen: '2025-01-01',
|
||||
resolved_on: null,
|
||||
extra: {},
|
||||
},
|
||||
],
|
||||
history: [],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a URL-routing global.fetch.
|
||||
*
|
||||
* The list device starts stale (resolution_date / remediation_plan = null). Only
|
||||
* AFTER a successful metadata PATCH does the list endpoint return the saved values —
|
||||
* modelling the backend, which already persists and returns the new metadata. Thus a
|
||||
* post-save re-fetch is observable in the row, while on unfixed code (no re-fetch) the
|
||||
* row keeps showing the stale value.
|
||||
*/
|
||||
function installFetchMock(savedResolutionDate, savedRemediationPlan) {
|
||||
const state = { patchOccurred: false, patchCalls: 0, listCalls: 0, listCallsAfterPatch: 0 };
|
||||
|
||||
global.fetch = jest.fn((url, options = {}) => {
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
|
||||
// PATCH /compliance/items/:hostname/metadata → success
|
||||
if (method === 'PATCH' && url.includes('/compliance/items/') && url.includes('/metadata')) {
|
||||
state.patchCalls++;
|
||||
state.patchOccurred = true;
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
// GET /compliance/summary?team=STEAM → minimal valid summary
|
||||
if (url.includes('/compliance/summary')) {
|
||||
return jsonResponse({ entries: [], overall_scores: {}, upload: { report_date: '2025-01-01' } });
|
||||
}
|
||||
|
||||
// GET /compliance/items?team=STEAM&status=active → device list (query string)
|
||||
if (url.includes('/compliance/items?')) {
|
||||
state.listCalls++;
|
||||
if (state.patchOccurred) state.listCallsAfterPatch++;
|
||||
const device = state.patchOccurred
|
||||
? makeListDevice({ resolution_date: savedResolutionDate, remediation_plan: savedRemediationPlan })
|
||||
: makeListDevice({ resolution_date: null, remediation_plan: null });
|
||||
return jsonResponse({ devices: [device] });
|
||||
}
|
||||
|
||||
// GET /compliance/items/:hostname → detail (one active metric)
|
||||
if (url.includes('/compliance/items/')) {
|
||||
return jsonResponse(makeDetail());
|
||||
}
|
||||
|
||||
// Any other URL (charts panel is mocked out) → safe empty payload
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// --- Generators ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* YYYY-MM-DD strings built from integer tuples. Do NOT call toISOString on shrunk
|
||||
* values (mirrors the predecessor exploration test's date-generator pattern).
|
||||
*/
|
||||
const arbResolutionDate = fc
|
||||
.tuple(
|
||||
fc.integer({ min: 2026, max: 2030 }),
|
||||
fc.integer({ min: 1, max: 12 }),
|
||||
fc.integer({ min: 1, max: 28 })
|
||||
)
|
||||
.map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
|
||||
|
||||
/** Non-empty, trimmed remediation plan strings, length-bounded 1–200. */
|
||||
const arbRemediationPlan = fc
|
||||
.string({ minLength: 1, maxLength: 200 })
|
||||
.filter((s) => s.trim().length > 0);
|
||||
|
||||
// --- Setup / teardown ------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// --- Property Test ---------------------------------------------------------
|
||||
|
||||
describe('Bug Condition Exploration: list row stays stale after sidebar metadata save', () => {
|
||||
it(
|
||||
'Property 1: for any saved metadata, the parent list row reflects the saved Resolution Date after a successful sidebar save (no manual refresh)',
|
||||
async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(arbResolutionDate, arbRemediationPlan, async (savedResolutionDate, savedRemediationPlan) => {
|
||||
const state = installFetchMock(savedResolutionDate, savedRemediationPlan);
|
||||
|
||||
try {
|
||||
const { container } = render(<CompliancePage onNavigate={() => {}} />);
|
||||
|
||||
// Wait for the (stale) device row, then capture the row element.
|
||||
const hostCell = await screen.findByText(HOSTNAME);
|
||||
const row = hostCell.parentElement;
|
||||
|
||||
// Pre-condition: the stale row does not yet show the to-be-saved date.
|
||||
expect(within(row).queryByText(savedResolutionDate)).toBeNull();
|
||||
|
||||
// Open the sidebar detail panel.
|
||||
fireEvent.click(row);
|
||||
|
||||
// Wait for the panel's editable Resolution Date input to render.
|
||||
const dateInput = await waitFor(() => {
|
||||
const el = container.querySelector('input[type="date"]');
|
||||
if (!el) throw new Error('resolution date input not ready');
|
||||
return el;
|
||||
});
|
||||
const planInput = screen.getByPlaceholderText(/Describe the remediation plan/i);
|
||||
|
||||
// Enter the generated metadata values and click Save.
|
||||
fireEvent.change(dateInput, { target: { value: savedResolutionDate } });
|
||||
fireEvent.change(planInput, { target: { value: savedRemediationPlan } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// The PATCH is issued on both fixed and unfixed code.
|
||||
await waitFor(() => expect(state.patchCalls).toBeGreaterThan(0));
|
||||
|
||||
// PROPERTY (expected behavior, bugfix.md 2.1/2.2): the parent list row
|
||||
// reflects the saved Resolution Date without a manual refresh. This holds
|
||||
// only if a parent refresh callback re-issued fetchDevices after the save.
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(within(row).getByText(savedResolutionDate)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
|
||||
// Supporting: the list endpoint was re-issued after the save.
|
||||
expect(state.listCallsAfterPatch).toBeGreaterThan(0);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 10, endOnFailure: true }
|
||||
);
|
||||
},
|
||||
60000
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* Preservation / Regression Property Tests:
|
||||
* Compliance List Stale After Sidebar Edit
|
||||
*
|
||||
* Spec: .kiro/specs/compliance-list-stale-after-sidebar-edit/ (bugfix)
|
||||
* Issue: #23 — "[Bug] Update Res Date/Remed Plan in list after updating in sidebar"
|
||||
* http://steam-gitlab.charterlab.com/steam/cve-dashboard/-/issues/23
|
||||
*
|
||||
* Property 2 (Preservation): the behaviors below must hold AFTER the fix. Following
|
||||
* observation-first methodology, the preservation properties (note-add refresh, failed
|
||||
* save surfaces an error without falsely updating the list, close-without-change clears
|
||||
* selection, other row fields render) were observed to hold on UNFIXED code and must
|
||||
* keep holding. The "regression guard" (the list row reflects a saved metadata value)
|
||||
* is the one behavior that flips from failing (pre-fix) to passing (post-fix); it is
|
||||
* kept here as a standing guard against re-introducing the bug.
|
||||
*
|
||||
* Mirrors the tagging convention of
|
||||
* backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js
|
||||
*
|
||||
* Properties tested:
|
||||
* P2.1 — Regression guard: after a successful sidebar save the list row shows the
|
||||
* saved Resolution Date with no manual refresh (bugfix.md 2.1, 2.2)
|
||||
* P2.2 — Note-add still triggers a list re-fetch via onNoteAdded (bugfix.md 3.1)
|
||||
* P2.3 — Failed metadata save surfaces metaError and does NOT
|
||||
* falsely update the list row (bugfix.md 3.3)
|
||||
* P2.4 — Close-without-change clears the selection, no PATCH (bugfix.md 3.4)
|
||||
* P2.5 — Other row fields (hostname, IP, type, failing metrics,
|
||||
* seen count) render unchanged (bugfix.md 3.5)
|
||||
*
|
||||
* **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6**
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
|
||||
// --- Mocks (hoisted by babel-jest above the CompliancePage import) ---
|
||||
|
||||
// Stub auth so CompliancePage renders the STEAM team with write access.
|
||||
jest.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
canWrite: () => true,
|
||||
isAdmin: () => false,
|
||||
getAvailableTeams: () => ['STEAM'],
|
||||
adminScope: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The historical charts panel is irrelevant to the list-staleness bug and pulls
|
||||
// in recharts; stub it so the tests stay focused and fast across property runs.
|
||||
jest.mock('../ComplianceChartsPanel', () => () => null);
|
||||
|
||||
import CompliancePage from '../CompliancePage';
|
||||
|
||||
const HOSTNAME = 'HOST-001';
|
||||
const METRIC_ID = '2.3.6i';
|
||||
|
||||
// --- Fixtures --------------------------------------------------------------
|
||||
|
||||
function jsonResponse(body, ok = true) {
|
||||
return Promise.resolve({ ok, json: async () => body });
|
||||
}
|
||||
|
||||
function makeListDevice({ resolution_date, remediation_plan }) {
|
||||
return {
|
||||
hostname: HOSTNAME,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'Switch',
|
||||
failing_metrics: [{ metric_id: METRIC_ID, category: 'Vulnerability Management' }],
|
||||
resolution_date,
|
||||
remediation_plan,
|
||||
seen_count: 3,
|
||||
has_notes: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail() {
|
||||
return {
|
||||
hostname: HOSTNAME,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'Switch',
|
||||
team: 'STEAM',
|
||||
metrics: [
|
||||
{
|
||||
metric_id: METRIC_ID,
|
||||
category: 'Vulnerability Management',
|
||||
status: 'active',
|
||||
metric_desc: 'Outbound encryption required on all endpoints',
|
||||
resolution_date: null,
|
||||
remediation_plan: null,
|
||||
seen_count: 3,
|
||||
first_seen: '2025-01-01',
|
||||
resolved_on: null,
|
||||
extra: {},
|
||||
},
|
||||
],
|
||||
history: [],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a URL-routing global.fetch shared by every property.
|
||||
*
|
||||
* Options:
|
||||
* savedResolutionDate / savedRemediationPlan — values the list endpoint returns
|
||||
* AFTER a successful metadata PATCH (models the backend persisting the new value).
|
||||
* patchOk — whether PATCH /compliance/items/:hostname/metadata succeeds (default true).
|
||||
* noteOk — whether POST /compliance/notes succeeds (default true).
|
||||
* fixedDevice — when provided, the list always returns this device unchanged (used by
|
||||
* the row-field rendering property which never edits metadata).
|
||||
*
|
||||
* The list device starts stale (resolution_date / remediation_plan = null) and only
|
||||
* returns the saved values once a SUCCESSFUL patch has occurred, so a post-save
|
||||
* re-fetch is observable in the rendered row.
|
||||
*/
|
||||
function installFetchMock(opts = {}) {
|
||||
const {
|
||||
savedResolutionDate = '2027-06-15',
|
||||
savedRemediationPlan = 'Patch firmware',
|
||||
patchOk = true,
|
||||
noteOk = true,
|
||||
fixedDevice = null,
|
||||
} = opts;
|
||||
|
||||
const state = {
|
||||
patchOccurred: false,
|
||||
patchCalls: 0,
|
||||
noteOccurred: false,
|
||||
noteCalls: 0,
|
||||
listCalls: 0,
|
||||
listCallsAfterPatch: 0,
|
||||
listCallsAfterNote: 0,
|
||||
};
|
||||
|
||||
global.fetch = jest.fn((url, options = {}) => {
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
|
||||
// POST /compliance/notes → success / failure
|
||||
if (method === 'POST' && url.includes('/compliance/notes')) {
|
||||
state.noteCalls++;
|
||||
if (noteOk) {
|
||||
state.noteOccurred = true;
|
||||
return jsonResponse({ ok: true, id: 1 });
|
||||
}
|
||||
return jsonResponse({ error: 'Failed to save note' }, false);
|
||||
}
|
||||
|
||||
// PATCH /compliance/items/:hostname/metadata → success / failure
|
||||
if (method === 'PATCH' && url.includes('/compliance/items/') && url.includes('/metadata')) {
|
||||
state.patchCalls++;
|
||||
if (patchOk) {
|
||||
state.patchOccurred = true;
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
return jsonResponse({ error: 'Failed to save metadata' }, false);
|
||||
}
|
||||
|
||||
// GET /compliance/summary?team=STEAM → minimal valid summary
|
||||
if (url.includes('/compliance/summary')) {
|
||||
return jsonResponse({ entries: [], overall_scores: {}, upload: { report_date: '2025-01-01' } });
|
||||
}
|
||||
|
||||
// GET /compliance/items?team=STEAM&status=active → device list (query string)
|
||||
if (url.includes('/compliance/items?')) {
|
||||
state.listCalls++;
|
||||
if (state.patchOccurred) state.listCallsAfterPatch++;
|
||||
if (state.noteOccurred) state.listCallsAfterNote++;
|
||||
let device;
|
||||
if (fixedDevice) {
|
||||
device = fixedDevice;
|
||||
} else if (state.patchOccurred) {
|
||||
device = makeListDevice({ resolution_date: savedResolutionDate, remediation_plan: savedRemediationPlan });
|
||||
} else {
|
||||
device = makeListDevice({ resolution_date: null, remediation_plan: null });
|
||||
}
|
||||
return jsonResponse({ devices: [device] });
|
||||
}
|
||||
|
||||
// GET /compliance/items/:hostname → detail (one active metric)
|
||||
if (url.includes('/compliance/items/')) {
|
||||
return jsonResponse(makeDetail());
|
||||
}
|
||||
|
||||
// Any other URL (charts panel is mocked out) → safe empty payload
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// --- Generators ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* YYYY-MM-DD strings built from integer tuples. Do NOT call toISOString on shrunk
|
||||
* values (mirrors the predecessor exploration test's date-generator pattern).
|
||||
*/
|
||||
const arbResolutionDate = fc
|
||||
.tuple(
|
||||
fc.integer({ min: 2026, max: 2030 }),
|
||||
fc.integer({ min: 1, max: 12 }),
|
||||
fc.integer({ min: 1, max: 28 })
|
||||
)
|
||||
.map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
|
||||
|
||||
/** Non-empty, trimmed remediation plan strings, length-bounded 1–200. */
|
||||
const arbRemediationPlan = fc
|
||||
.string({ minLength: 1, maxLength: 200 })
|
||||
.filter((s) => s.trim().length > 0);
|
||||
|
||||
/** Non-empty, trimmed note text, length-bounded 1–200. */
|
||||
const arbNoteText = fc
|
||||
.string({ minLength: 1, maxLength: 200 })
|
||||
.filter((s) => s.trim().length > 0);
|
||||
|
||||
/** Hostnames like HOST-0001 — unique, regex-safe, distinct from IP/type cells. */
|
||||
const arbHostname = fc
|
||||
.integer({ min: 1, max: 9999 })
|
||||
.map((n) => `HOST-${String(n).padStart(4, '0')}`);
|
||||
|
||||
/** IPv4 dotted-quad strings built from integer tuples. */
|
||||
const arbIp = fc
|
||||
.tuple(
|
||||
fc.integer({ min: 1, max: 254 }),
|
||||
fc.integer({ min: 0, max: 255 }),
|
||||
fc.integer({ min: 0, max: 255 }),
|
||||
fc.integer({ min: 1, max: 254 })
|
||||
)
|
||||
.map(([a, b, c, d]) => `${a}.${b}.${c}.${d}`);
|
||||
|
||||
const arbDeviceType = fc.constantFrom('Switch', 'Router', 'Firewall', 'Server', 'Workstation');
|
||||
|
||||
/** metric_id like "7.1.3", built from integer tuples. */
|
||||
const arbMetricId = fc
|
||||
.tuple(fc.integer({ min: 1, max: 9 }), fc.integer({ min: 1, max: 9 }), fc.integer({ min: 1, max: 9 }))
|
||||
.map(([a, b, c]) => `${a}.${b}.${c}`);
|
||||
|
||||
/** 1–3 unique metric ids (unique to avoid duplicate React keys / ambiguous queries). */
|
||||
const arbMetricIds = fc.uniqueArray(arbMetricId, { minLength: 1, maxLength: 3 });
|
||||
|
||||
const arbSeenCount = fc.integer({ min: 1, max: 20 });
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------
|
||||
|
||||
/** Render CompliancePage, wait for the device row, return { container, row }. */
|
||||
async function renderAndGetRow(hostname = HOSTNAME) {
|
||||
const utils = render(<CompliancePage onNavigate={() => {}} />);
|
||||
const hostCell = await screen.findByText(hostname);
|
||||
return { ...utils, row: hostCell.parentElement };
|
||||
}
|
||||
|
||||
/** Open the sidebar by clicking the row, wait for the editable date input. */
|
||||
async function openPanel(row, container) {
|
||||
fireEvent.click(row);
|
||||
const dateInput = await waitFor(() => {
|
||||
const el = container.querySelector('input[type="date"]');
|
||||
if (!el) throw new Error('resolution date input not ready');
|
||||
return el;
|
||||
});
|
||||
return dateInput;
|
||||
}
|
||||
|
||||
// --- Setup / teardown ------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// P2.1 — Regression guard: list row reflects a saved Resolution Date
|
||||
// after a successful sidebar save, with no manual refresh.
|
||||
// (bugfix.md 2.1, 2.2 — kept as a standing regression guard)
|
||||
// ===========================================================================
|
||||
describe('P2.1 — Regression guard: list row reflects saved metadata after a successful save', () => {
|
||||
it('for any saved metadata, the parent row shows the saved Resolution Date without a manual refresh', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(arbResolutionDate, arbRemediationPlan, async (savedResolutionDate, savedRemediationPlan) => {
|
||||
const state = installFetchMock({ savedResolutionDate, savedRemediationPlan });
|
||||
try {
|
||||
const { container, row } = await renderAndGetRow();
|
||||
|
||||
// Pre-condition: the stale row does not yet show the to-be-saved date.
|
||||
expect(within(row).queryByText(savedResolutionDate)).toBeNull();
|
||||
|
||||
const dateInput = await openPanel(row, container);
|
||||
const planInput = screen.getByPlaceholderText(/Describe the remediation plan/i);
|
||||
|
||||
fireEvent.change(dateInput, { target: { value: savedResolutionDate } });
|
||||
fireEvent.change(planInput, { target: { value: savedRemediationPlan } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => expect(state.patchCalls).toBeGreaterThan(0));
|
||||
|
||||
// Regression guard: the row reflects the saved value (the fix re-fetches).
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(within(row).getByText(savedResolutionDate)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
expect(state.listCallsAfterPatch).toBeGreaterThan(0);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 6, endOnFailure: true }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// P2.2 — Note-add refresh still works: adding a note re-issues the list GET
|
||||
// via the existing onNoteAdded callback. (bugfix.md 3.1)
|
||||
// ===========================================================================
|
||||
describe('P2.2 — Note-add still triggers a list re-fetch (onNoteAdded preserved)', () => {
|
||||
it('for any note text, a successful note add re-issues GET /compliance/items', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(arbNoteText, async (noteText) => {
|
||||
const state = installFetchMock({ noteOk: true });
|
||||
try {
|
||||
const { container, row } = await renderAndGetRow();
|
||||
await openPanel(row, container);
|
||||
|
||||
const noteInput = await screen.findByPlaceholderText(/Add a note/i);
|
||||
fireEvent.change(noteInput, { target: { value: noteText } });
|
||||
|
||||
const addButton = container.querySelector('.lucide-send').closest('button');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => expect(state.noteCalls).toBeGreaterThan(0));
|
||||
// Preservation: the list is re-fetched after the note add.
|
||||
await waitFor(() => expect(state.listCallsAfterNote).toBeGreaterThan(0), { timeout: 2000 });
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 6, endOnFailure: true }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// P2.3 — Failed metadata save surfaces metaError and does NOT falsely
|
||||
// update the list row. (bugfix.md 3.3)
|
||||
// ===========================================================================
|
||||
describe('P2.3 — Failed save shows an error and does not falsely update the list', () => {
|
||||
it('for any attempted value, a non-OK PATCH surfaces metaError and the row stays stale', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(arbResolutionDate, async (attemptedResolutionDate) => {
|
||||
const state = installFetchMock({ savedResolutionDate: attemptedResolutionDate, patchOk: false });
|
||||
try {
|
||||
const { container, row } = await renderAndGetRow();
|
||||
const dateInput = await openPanel(row, container);
|
||||
|
||||
fireEvent.change(dateInput, { target: { value: attemptedResolutionDate } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => expect(state.patchCalls).toBeGreaterThan(0));
|
||||
|
||||
// The panel surfaces the error from the failed save.
|
||||
await waitFor(() => expect(screen.getByText('Failed to save metadata')).toBeInTheDocument());
|
||||
|
||||
// The list row was NOT updated to a value that never persisted.
|
||||
expect(within(row).queryByText(attemptedResolutionDate)).toBeNull();
|
||||
expect(state.listCallsAfterPatch).toBe(0);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 6, endOnFailure: true }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// P2.4 — Close-without-change clears the selection and issues no PATCH.
|
||||
// (bugfix.md 3.4) — example-style assertion (no value in varying inputs).
|
||||
// ===========================================================================
|
||||
describe('P2.4 — Close without change clears selection and saves nothing', () => {
|
||||
it('clicking the close (X) removes the panel and triggers no metadata save', async () => {
|
||||
const state = installFetchMock();
|
||||
const { container, row } = await renderAndGetRow();
|
||||
|
||||
// Open the panel and confirm the editable date input rendered.
|
||||
await openPanel(row, container);
|
||||
|
||||
// Click the close (X) control without saving anything.
|
||||
const closeButton = container.querySelector('.lucide-x').closest('button');
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
// The panel is gone (selection cleared) — its date input no longer exists.
|
||||
await waitFor(() => expect(container.querySelector('input[type="date"]')).toBeNull());
|
||||
|
||||
// The row remains and no metadata save was attempted.
|
||||
expect(screen.getByText(HOSTNAME)).toBeInTheDocument();
|
||||
expect(state.patchCalls).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
it('clicking the backdrop also clears selection and triggers no metadata save', async () => {
|
||||
const state = installFetchMock();
|
||||
const { container, row } = await renderAndGetRow();
|
||||
|
||||
await openPanel(row, container);
|
||||
|
||||
// The first fixed/inset overlay is the backdrop (onClick={onClose}).
|
||||
const backdrop = container.querySelector('div[style*="position: fixed"]');
|
||||
fireEvent.click(backdrop);
|
||||
|
||||
await waitFor(() => expect(container.querySelector('input[type="date"]')).toBeNull());
|
||||
expect(screen.getByText(HOSTNAME)).toBeInTheDocument();
|
||||
expect(state.patchCalls).toBe(0);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// P2.5 — Other row fields render unchanged: hostname, IP, device type,
|
||||
// failing metrics, seen count. (bugfix.md 3.5)
|
||||
// ===========================================================================
|
||||
describe('P2.5 — Other row fields render correctly and unchanged', () => {
|
||||
it('for any generated device, the row displays hostname, IP, type, metrics, and seen count', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
arbHostname,
|
||||
arbIp,
|
||||
arbDeviceType,
|
||||
arbMetricIds,
|
||||
arbSeenCount,
|
||||
async (hostname, ip, deviceType, metricIds, seenCount) => {
|
||||
const device = {
|
||||
hostname,
|
||||
ip_address: ip,
|
||||
device_type: deviceType,
|
||||
failing_metrics: metricIds.map((id) => ({ metric_id: id, category: 'Vulnerability Management' })),
|
||||
resolution_date: null,
|
||||
remediation_plan: null,
|
||||
seen_count: seenCount,
|
||||
has_notes: false,
|
||||
};
|
||||
installFetchMock({ fixedDevice: device });
|
||||
try {
|
||||
const { row } = await renderAndGetRow(hostname);
|
||||
|
||||
// Hostname, IP, and device type render verbatim.
|
||||
expect(within(row).getByText(hostname)).toBeInTheDocument();
|
||||
expect(within(row).getByText(ip)).toBeInTheDocument();
|
||||
expect(within(row).getByText(deviceType)).toBeInTheDocument();
|
||||
|
||||
// Every failing-metric badge renders its metric_id.
|
||||
for (const id of metricIds) {
|
||||
expect(within(row).getByText(id)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
// The seen-count badge renders "<count>×".
|
||||
expect(within(row).getByText(`${seenCount}\u00D7`)).toBeInTheDocument();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 8, endOnFailure: true }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
@@ -43,6 +43,14 @@ function isValidCalendarYmd(s) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// True iff `s` is an ISO datetime string whose date prefix is a valid calendar date.
|
||||
// e.g. "2026-07-01T00:00:00.000Z" → true (the helper now extracts the date prefix).
|
||||
function isIsoDateTimeWithValidDate(s) {
|
||||
if (typeof s !== 'string') return false;
|
||||
if (!/^\d{4}-\d{2}-\d{2}T/.test(s)) return false;
|
||||
return isValidCalendarYmd(s.slice(0, 10));
|
||||
}
|
||||
|
||||
// --- Shared arbitraries -----------------------------------------------------
|
||||
|
||||
// Four-digit zero-padded year string (0000–9999) — always matches \d{4}.
|
||||
@@ -130,7 +138,7 @@ const wrongShapeArb = fc.oneof(
|
||||
|
||||
const invalidStringArb = fc
|
||||
.oneof(wrongShapeArb, badMonthArb, badDayArb, impossibleDayArb, twoDigitArb)
|
||||
.filter(s => typeof s === 'string' && s.trim() !== '' && !isValidCalendarYmd(s.trim()));
|
||||
.filter(s => typeof s === 'string' && s.trim() !== '' && !isValidCalendarYmd(s.trim()) && !isIsoDateTimeWithValidDate(s.trim()));
|
||||
|
||||
// Any input category (used for totality / independence properties).
|
||||
const anyInputArb = fc.oneof(
|
||||
|
||||
@@ -32,6 +32,13 @@ describe('formatResolutionDate', () => {
|
||||
value: '2024-02-29',
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies an ISO datetime '2026-07-03T00:00:00.000Z' as set with the date prefix", () => {
|
||||
expect(formatResolutionDate('2026-07-03T00:00:00.000Z')).toEqual({
|
||||
state: 'set',
|
||||
value: '2026-07-03',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid — present but not a valid calendar date (Requirement 1.6)', () => {
|
||||
|
||||
@@ -63,14 +63,20 @@ export function formatResolutionDate(raw) {
|
||||
}
|
||||
|
||||
// Must match the strict YYYY-MM-DD shape.
|
||||
if (!YMD_SHAPE.test(trimmed)) {
|
||||
// Also accept ISO datetime strings (e.g. "2026-07-03T00:00:00.000Z") by
|
||||
// extracting the date prefix — the pg driver returns DATE columns this way.
|
||||
let candidate = trimmed;
|
||||
if (!YMD_SHAPE.test(candidate) && /^\d{4}-\d{2}-\d{2}T/.test(candidate)) {
|
||||
candidate = candidate.slice(0, 10);
|
||||
}
|
||||
if (!YMD_SHAPE.test(candidate)) {
|
||||
return { state: 'invalid' };
|
||||
}
|
||||
|
||||
// Shape is correct; verify it is a real calendar date.
|
||||
const year = Number(trimmed.slice(0, 4));
|
||||
const month = Number(trimmed.slice(5, 7));
|
||||
const day = Number(trimmed.slice(8, 10));
|
||||
const year = Number(candidate.slice(0, 4));
|
||||
const month = Number(candidate.slice(5, 7));
|
||||
const day = Number(candidate.slice(8, 10));
|
||||
|
||||
if (month < 1 || month > 12) {
|
||||
return { state: 'invalid' };
|
||||
@@ -80,7 +86,7 @@ export function formatResolutionDate(raw) {
|
||||
return { state: 'invalid' };
|
||||
}
|
||||
|
||||
// Valid calendar date; the trimmed value is already the canonical
|
||||
// Valid calendar date; the candidate value is the canonical
|
||||
// zero-padded YYYY-MM-DD form.
|
||||
return { state: 'set', value: trimmed };
|
||||
return { state: 'set', value: candidate };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user