13 Commits

Author SHA1 Message Date
Jordan Ramos
e8aa7038ad Release v2.2.0 2026-06-04 11:16:45 -06:00
Jordan Ramos
e887fa8946 Add CARD ownership tooltip and direct action modal on IP hover
Hover over any IP address in the findings table to see CARD ownership data
(confirmed/unconfirmed/candidate teams) in an interactive tooltip. Click
'Actions' to open a full modal for confirm/decline/redirect — no queue
item required.

Backend:
- Add direct /api/card/owner/:assetId/confirm|decline|redirect endpoints
- Add quick mode to resolveAssetId (CTEC only, 15s timeout) for tooltip use
- owner-lookup supports ?quick=1 query param with 504 on timeout
- getOwner accepts options for custom timeout

Frontend:
- New CardOwnerTooltip component (portal, hover bridge, cached results)
- New CardDetailModal for confirm/decline/redirect from tooltip
- IP cells show help cursor, trigger tooltip on 400ms hover
- Timeouts (504) not cached — retry on re-hover
- Teams fetch retries silently up to 3x on failure
- Redirect dropdowns show owner-data teams as fallback when teams API fails
2026-06-04 11:15:13 -06:00
Jordan Ramos
d9c47ec030 Add Group by Host toggle to Ivanti findings table
Client-side grouping that collapses duplicate assets (same hostname + IP)
with multiple finding IDs into expandable host rows. Hosts with only one
finding remain as normal flat rows.

- Toggle button in toolbar switches between flat and grouped views
- Group header rows preserve column alignment (severity, host, IP in proper columns)
- Expanded sub-rows show full finding details with all interactions intact
- Selection, queue, hide, and workflow actions all work in both modes
- Groups sorted by highest severity; expand/collapse all controls included
2026-06-03 15:44:48 -06:00
Jordan Ramos
4e8f4cbb10 Allow redirecting pending queue items in place without duplicating
Previously, redirecting a queue item required completing it first, which
created a duplicate entry. Now:
- Pending items: redirect updates workflow_type in place (no new row)
- Completed items: still creates a new pending item (legacy behavior)
- Redirect arrow now visible on all items, not just completed ones
- Frontend handles in-place updates by replacing the item in state
2026-06-03 13:55:10 -06:00
Jordan Ramos
1cc8bd5a4c Improve CARD decline error diagnostics and prevent accidental modal dismiss
- Log the full owner response in audit when update_token is missing so
  we can see what CARD actually returned
- Improve error message to suggest the asset may have already been actioned
- Remove backdrop-click-to-close on TemplateFormModal to prevent
  accidental data loss while filling in template content
2026-06-03 13:33:24 -06:00
Jordan Ramos
50f14c14d2 Add inline view panel to Template Manager with copy buttons
Click the model name or eye icon to expand a read-only view of all
template sections with per-section copy-to-clipboard and Copy All.
2026-06-03 11:04:25 -06:00
Jordan Ramos
4f40850fd2 ci: force pipeline refresh for v2.1.0 deploy 2026-06-03 07:47:30 -06:00
Jordan Ramos
e4abf8dc9b Update CHANGELOG for v2.1.0 release
Add Archer Template Library to the feature list.
2026-06-02 16:09:28 -06:00
Jordan Ramos
3500787851 Add Archer Template Library for risk acceptance form reuse
Adds a template management system to the Ivanti Queue's Archer Risk
Acceptance workflow. Templates store static form content (Environment
Overview, Segmentation, Mitigating Controls, etc.) organized by
Vendor > Platform > Model hierarchy.

Features:
- Full CRUD API at /api/archer-templates with search, filter, clone,
  and hierarchy navigation endpoints
- Template Manager page (nav: Template Mgr) with grouped list view,
  create/edit/clone/delete modals, role-based access
- TemplateSelector component integrated into Ivanti Todo Queue for
  Archer workflow items with per-section copy-to-clipboard buttons
  and Copy All functionality
- Database migration with case-insensitive uniqueness enforcement
- Audit logging for all template mutations

New files:
- backend/migrations/add_archer_templates_table.js
- backend/routes/archerTemplates.js
- frontend/src/components/pages/ArcherTemplatePage.js
- frontend/src/components/TemplateSelector.js
- frontend/src/components/TemplateFormModal.js
- frontend/src/components/DeleteConfirmModal.js
2026-06-02 16:08:25 -06:00
Jordan Ramos
c5225c96a5 Fix 'invalid date' display for ISO datetime resolution_date values
The pg driver returns PostgreSQL DATE columns as ISO datetime strings
(e.g. '2026-07-03T00:00:00.000Z'). The formatResolutionDate helper was
strictly matching YYYY-MM-DD only, so these were classified as 'invalid'.

Now the helper extracts the date prefix from ISO datetime strings before
validating, correctly classifying them as 'set' with the YYYY-MM-DD value.
Updated the property test filter and added an example test for the case.
2026-06-02 14:12:13 -06:00
Jordan Ramos
aae09020e6 Sort metrics numerically on the CCP Metrics page
Add a natural-sort comparator for metric IDs (e.g. 2.3.6i, 5.2.6, 10.1.1)
and apply it to the metric breakdown cards, the vertical detail table, and
the forecast burndown metric dropdown. Metrics now appear in ascending
numerical order instead of arbitrary API response order.

Closes #24
2026-06-02 12:17:28 -06:00
Jordan Ramos
0cf49e6ef1 Move resolution date/remediation plan below failing metrics and fix date picker contrast
The Resolution Date, Remediation Plan, and Apply To Metrics sections
now render immediately after Failing Metrics in the sidebar instead of
after Resolved Metrics and History — no more scrolling past unrelated
sections to reach the edit fields.

The date input also gains colorScheme: 'dark' so the native browser
calendar picker renders with light text on a dark background, fixing
the black-on-dark-blue readability issue.

Closes #21
Closes #22
2026-06-02 12:09:29 -06:00
Jordan Ramos
7545457813 Refresh compliance list after sidebar metadata save
The host list on the compliance page showed stale resolution date and
remediation plan values after editing them in the detail sidebar, until
an unrelated refresh (filter, team, or tab change) ran. handleSaveMetadata
re-fetched only the panel's own detail and never notified the parent.

Add an onMetadataSaved callback invoked after a successful metadata PATCH
and wire it to the existing list refresh in CompliancePage, mirroring the
onNoteAdded pattern. The list now reflects saved changes immediately.

Closes #23
2026-06-02 11:00:38 -06:00
27 changed files with 4961 additions and 211 deletions

View File

@@ -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.
# =============================================================================

View File

@@ -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

View File

@@ -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;

View 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));
}

View File

@@ -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() {

View 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;

View File

@@ -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
*

View File

@@ -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,
},

View File

@@ -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());

View File

@@ -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; })()}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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' };

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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' }} />}>

View File

@@ -697,6 +697,7 @@ export default function CompliancePage({ onNavigate }) {
hostname={selectedHost}
onClose={() => setSelectedHost(null)}
onNoteAdded={refresh}
onMetadataSaved={refresh}
onNavigate={onNavigate}
/>
)}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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.11.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 1200. */
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
);
});

View File

@@ -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 1200. */
const arbRemediationPlan = fc
.string({ minLength: 1, maxLength: 200 })
.filter((s) => s.trim().length > 0);
/** Non-empty, trimmed note text, length-bounded 1200. */
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}`);
/** 13 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);
});

View File

@@ -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 (00009999) — 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(

View File

@@ -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)', () => {

View File

@@ -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 };
}