Add Archer Template Library for risk acceptance form reuse
Adds a template management system to the Ivanti Queue's Archer Risk Acceptance workflow. Templates store static form content (Environment Overview, Segmentation, Mitigating Controls, etc.) organized by Vendor > Platform > Model hierarchy. Features: - Full CRUD API at /api/archer-templates with search, filter, clone, and hierarchy navigation endpoints - Template Manager page (nav: Template Mgr) with grouped list view, create/edit/clone/delete modals, role-based access - TemplateSelector component integrated into Ivanti Todo Queue for Archer workflow items with per-section copy-to-clipboard buttons and Copy All functionality - Database migration with case-insensitive uniqueness enforcement - Audit logging for all template mutations New files: - backend/migrations/add_archer_templates_table.js - backend/routes/archerTemplates.js - frontend/src/components/pages/ArcherTemplatePage.js - frontend/src/components/TemplateSelector.js - frontend/src/components/TemplateFormModal.js - frontend/src/components/DeleteConfirmModal.js
This commit is contained in:
60
backend/migrations/add_archer_templates_table.js
Normal file
60
backend/migrations/add_archer_templates_table.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Migration: Add archer_templates table for the Archer Template Library feature
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting archer_templates table migration...');
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS archer_templates (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
vendor VARCHAR(100) NOT NULL,
|
||||||
|
platform VARCHAR(100) NOT NULL,
|
||||||
|
model VARCHAR(100) NOT NULL,
|
||||||
|
environment_overview TEXT NOT NULL DEFAULT '',
|
||||||
|
segmentation TEXT NOT NULL DEFAULT '',
|
||||||
|
mitigating_controls TEXT NOT NULL DEFAULT '',
|
||||||
|
additional_info TEXT NOT NULL DEFAULT '',
|
||||||
|
charter_network_banner TEXT NOT NULL DEFAULT '',
|
||||||
|
data_classification TEXT NOT NULL DEFAULT '',
|
||||||
|
charter_network TEXT NOT NULL DEFAULT '',
|
||||||
|
additional_access_list TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ archer_templates table created (or already exists)');
|
||||||
|
|
||||||
|
// Case-insensitive uniqueness on trimmed vendor/platform/model
|
||||||
|
await pool.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_archer_templates_unique_combo
|
||||||
|
ON archer_templates (LOWER(TRIM(vendor)), LOWER(TRIM(platform)), LOWER(TRIM(model)))
|
||||||
|
`);
|
||||||
|
console.log('✓ idx_archer_templates_unique_combo index created (or already exists)');
|
||||||
|
|
||||||
|
// Indexes for list query performance
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archer_templates_vendor
|
||||||
|
ON archer_templates(vendor)
|
||||||
|
`);
|
||||||
|
console.log('✓ idx_archer_templates_vendor index created (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archer_templates_platform
|
||||||
|
ON archer_templates(platform)
|
||||||
|
`);
|
||||||
|
console.log('✓ idx_archer_templates_platform index created (or already exists)');
|
||||||
|
|
||||||
|
console.log('Migration complete.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { run };
|
||||||
|
|
||||||
|
// Self-execute when run directly
|
||||||
|
if (require.main === module) {
|
||||||
|
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ const POSTGRES_MIGRATIONS = [
|
|||||||
'add_multi_item_jira_ticket.js',
|
'add_multi_item_jira_ticket.js',
|
||||||
'drop_jira_status_check_constraint.js',
|
'drop_jira_status_check_constraint.js',
|
||||||
'add_compliance_history_metric_id.js',
|
'add_compliance_history_metric_id.js',
|
||||||
|
'add_archer_templates_table.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
|
|||||||
543
backend/routes/archerTemplates.js
Normal file
543
backend/routes/archerTemplates.js
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
// routes/archerTemplates.js
|
||||||
|
const express = require('express');
|
||||||
|
const pool = require('../db');
|
||||||
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||||
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
|
// Section fields and their max length
|
||||||
|
const SECTION_FIELDS = [
|
||||||
|
'environment_overview',
|
||||||
|
'segmentation',
|
||||||
|
'mitigating_controls',
|
||||||
|
'additional_info',
|
||||||
|
'charter_network_banner',
|
||||||
|
'data_classification',
|
||||||
|
'charter_network',
|
||||||
|
'additional_access_list'
|
||||||
|
];
|
||||||
|
const SECTION_MAX_LENGTH = 10000;
|
||||||
|
|
||||||
|
function createArcherTemplatesRouter() {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// --- Hierarchy endpoints (MUST be defined before /:id to avoid route conflicts) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/archer-templates/hierarchy/vendors
|
||||||
|
*
|
||||||
|
* Returns a sorted array of distinct vendor names across all templates.
|
||||||
|
*
|
||||||
|
* @returns {string[]} 200 - Array of vendor names sorted alphabetically
|
||||||
|
* @returns {object} 500 - { error: 'Internal server error' }
|
||||||
|
*/
|
||||||
|
router.get('/hierarchy/vendors', requireAuth(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT DISTINCT vendor FROM archer_templates ORDER BY vendor ASC'
|
||||||
|
);
|
||||||
|
res.json(rows.map(r => r.vendor));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching template vendors:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/archer-templates/hierarchy/platforms
|
||||||
|
*
|
||||||
|
* Returns a sorted array of distinct platform names for a given vendor.
|
||||||
|
*
|
||||||
|
* @query {string} vendor - (required) The vendor to filter platforms by
|
||||||
|
* @returns {string[]} 200 - Array of platform names sorted alphabetically
|
||||||
|
* @returns {object} 400 - { error: 'vendor query parameter is required' }
|
||||||
|
* @returns {object} 500 - { error: 'Internal server error' }
|
||||||
|
*/
|
||||||
|
router.get('/hierarchy/platforms', requireAuth(), async (req, res) => {
|
||||||
|
const { vendor } = req.query;
|
||||||
|
if (!vendor) {
|
||||||
|
return res.status(400).json({ error: 'vendor query parameter is required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT DISTINCT platform FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) ORDER BY platform ASC',
|
||||||
|
[vendor]
|
||||||
|
);
|
||||||
|
res.json(rows.map(r => r.platform));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching template platforms:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/archer-templates/hierarchy/models
|
||||||
|
*
|
||||||
|
* Returns a sorted array of distinct model names for a given vendor and platform.
|
||||||
|
*
|
||||||
|
* @query {string} vendor - (required) The vendor to filter by
|
||||||
|
* @query {string} platform - (required) The platform to filter by
|
||||||
|
* @returns {string[]} 200 - Array of model names sorted alphabetically
|
||||||
|
* @returns {object} 400 - { error: 'Missing required query parameters: ...' }
|
||||||
|
* @returns {object} 500 - { error: 'Internal server error' }
|
||||||
|
*/
|
||||||
|
router.get('/hierarchy/models', requireAuth(), async (req, res) => {
|
||||||
|
const { vendor, platform } = req.query;
|
||||||
|
const missing = [];
|
||||||
|
if (!vendor) missing.push('vendor');
|
||||||
|
if (!platform) missing.push('platform');
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return res.status(400).json({ error: `Missing required query parameters: ${missing.join(', ')}` });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT DISTINCT model FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) AND LOWER(TRIM(platform)) = LOWER(TRIM($2)) ORDER BY model ASC',
|
||||||
|
[vendor, platform]
|
||||||
|
);
|
||||||
|
res.json(rows.map(r => r.model));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching template models:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Core CRUD endpoints ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/archer-templates
|
||||||
|
*
|
||||||
|
* Creates a new Archer template with vendor/platform/model hierarchy and section content.
|
||||||
|
* Requires Admin or Standard_User group.
|
||||||
|
*
|
||||||
|
* @body {string} vendor - (required) Vendor name, 1-100 chars after trim
|
||||||
|
* @body {string} platform - (required) Platform name, 1-100 chars after trim
|
||||||
|
* @body {string} model - (required) Model name, 1-100 chars after trim
|
||||||
|
* @body {string} [environment_overview] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [segmentation] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [mitigating_controls] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [additional_info] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [charter_network_banner] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [data_classification] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [charter_network] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [additional_access_list] - Section content, max 10,000 chars
|
||||||
|
* @returns {object} 201 - The created template record (all columns)
|
||||||
|
* @returns {object} 400 - { error: 'validation message' }
|
||||||
|
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
||||||
|
* @returns {object} 500 - { error: 'Internal server error' }
|
||||||
|
*/
|
||||||
|
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
const { vendor, platform, model } = req.body;
|
||||||
|
|
||||||
|
// Validate vendor, platform, model — required, 1-100 chars after trim, non-empty after trim
|
||||||
|
const errors = [];
|
||||||
|
for (const [field, value] of [['vendor', vendor], ['platform', platform], ['model', model]]) {
|
||||||
|
if (value === undefined || value === null || typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
errors.push(`${field} is required`);
|
||||||
|
} else if (value.trim().length > 100) {
|
||||||
|
errors.push(`${field} must be 100 characters or fewer`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return res.status(400).json({ error: errors.join('; ') });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate section fields — max 10,000 chars each, default to empty string
|
||||||
|
const sectionValues = {};
|
||||||
|
for (const field of SECTION_FIELDS) {
|
||||||
|
const val = req.body[field];
|
||||||
|
if (val !== undefined && val !== null && typeof val === 'string') {
|
||||||
|
if (val.length > SECTION_MAX_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `${field} must be 10,000 characters or fewer` });
|
||||||
|
}
|
||||||
|
sectionValues[field] = val;
|
||||||
|
} else {
|
||||||
|
sectionValues[field] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO archer_templates (vendor, platform, model, environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
vendor.trim(),
|
||||||
|
platform.trim(),
|
||||||
|
model.trim(),
|
||||||
|
sectionValues.environment_overview,
|
||||||
|
sectionValues.segmentation,
|
||||||
|
sectionValues.mitigating_controls,
|
||||||
|
sectionValues.additional_info,
|
||||||
|
sectionValues.charter_network_banner,
|
||||||
|
sectionValues.data_classification,
|
||||||
|
sectionValues.charter_network,
|
||||||
|
sectionValues.additional_access_list,
|
||||||
|
req.user.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fire-and-forget audit log
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'template_created',
|
||||||
|
entityType: 'archer_template',
|
||||||
|
entityId: String(rows[0].id),
|
||||||
|
details: { vendor: vendor.trim(), platform: platform.trim(), model: model.trim() },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
||||||
|
}
|
||||||
|
console.error('Error creating template:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/archer-templates
|
||||||
|
*
|
||||||
|
* Lists all templates with optional search and exact-match filters.
|
||||||
|
* Results are sorted by vendor, platform, model (ascending).
|
||||||
|
*
|
||||||
|
* @query {string} [search] - Substring search across vendor, platform, and model (ILIKE)
|
||||||
|
* @query {string} [vendor] - Exact-match filter on vendor (case-insensitive)
|
||||||
|
* @query {string} [platform] - Exact-match filter on platform (case-insensitive)
|
||||||
|
* @query {string} [model] - Exact-match filter on model (case-insensitive)
|
||||||
|
* @returns {object[]} 200 - Array of template records sorted by vendor/platform/model
|
||||||
|
* @returns {object} 500 - { error: 'Internal server error' }
|
||||||
|
*/
|
||||||
|
router.get('/', requireAuth(), async (req, res) => {
|
||||||
|
const { search, vendor, platform, model } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM archer_templates WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// Search — ILIKE substring match across vendor, platform, model
|
||||||
|
const trimmedSearch = search ? search.trim() : '';
|
||||||
|
if (trimmedSearch.length > 0) {
|
||||||
|
query += ` AND (vendor ILIKE $${paramIndex} OR platform ILIKE $${paramIndex} OR model ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${trimmedSearch}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact-match filters (case-insensitive via LOWER/TRIM)
|
||||||
|
if (vendor) {
|
||||||
|
query += ` AND LOWER(TRIM(vendor)) = LOWER(TRIM($${paramIndex}))`;
|
||||||
|
params.push(vendor);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (platform) {
|
||||||
|
query += ` AND LOWER(TRIM(platform)) = LOWER(TRIM($${paramIndex}))`;
|
||||||
|
params.push(platform);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (model) {
|
||||||
|
query += ` AND LOWER(TRIM(model)) = LOWER(TRIM($${paramIndex}))`;
|
||||||
|
params.push(model);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY vendor ASC, platform ASC, model ASC';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(query, params);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching templates:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/archer-templates/:id/clone
|
||||||
|
*
|
||||||
|
* Clones an existing template's section content into a new template with different
|
||||||
|
* vendor/platform/model hierarchy values. Requires Admin or Standard_User group.
|
||||||
|
*
|
||||||
|
* @param {number} id - The ID of the source template to clone from
|
||||||
|
* @body {string} vendor - (required) New vendor name, 1-100 chars after trim
|
||||||
|
* @body {string} platform - (required) New platform name, 1-100 chars after trim
|
||||||
|
* @body {string} model - (required) New model name, 1-100 chars after trim
|
||||||
|
* @returns {object} 201 - The newly created cloned template record
|
||||||
|
* @returns {object} 400 - { error: 'validation message' }
|
||||||
|
* @returns {object} 404 - { error: 'Template not found' }
|
||||||
|
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
||||||
|
* @returns {object} 500 - { error: 'Internal server error' }
|
||||||
|
*/
|
||||||
|
router.post('/:id/clone', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { vendor, platform, model } = req.body;
|
||||||
|
|
||||||
|
// Validate vendor, platform, model — required, 1-100 chars after trim, non-empty after trim
|
||||||
|
const errors = [];
|
||||||
|
for (const [field, value] of [['vendor', vendor], ['platform', platform], ['model', model]]) {
|
||||||
|
if (value === undefined || value === null || typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
errors.push(`${field} is required`);
|
||||||
|
} else if (value.trim().length > 100) {
|
||||||
|
errors.push(`${field} must be 100 characters or fewer`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return res.status(400).json({ error: errors.join('; ') });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify source template exists
|
||||||
|
const { rows: sourceRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||||
|
if (sourceRows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Template not found' });
|
||||||
|
}
|
||||||
|
const source = sourceRows[0];
|
||||||
|
|
||||||
|
// INSERT copying all 8 section fields from source with new hierarchy values
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO archer_templates (vendor, platform, model, environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
vendor.trim(),
|
||||||
|
platform.trim(),
|
||||||
|
model.trim(),
|
||||||
|
source.environment_overview,
|
||||||
|
source.segmentation,
|
||||||
|
source.mitigating_controls,
|
||||||
|
source.additional_info,
|
||||||
|
source.charter_network_banner,
|
||||||
|
source.data_classification,
|
||||||
|
source.charter_network,
|
||||||
|
source.additional_access_list,
|
||||||
|
req.user.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fire-and-forget audit log
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'template_cloned',
|
||||||
|
entityType: 'archer_template',
|
||||||
|
entityId: String(rows[0].id),
|
||||||
|
details: { sourceId: Number(id), newId: rows[0].id, vendor: vendor.trim(), platform: platform.trim(), model: model.trim() },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
||||||
|
}
|
||||||
|
console.error('Error cloning template:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/archer-templates/:id
|
||||||
|
*
|
||||||
|
* Fetches a single template by its ID.
|
||||||
|
*
|
||||||
|
* @param {number} id - The template ID
|
||||||
|
* @returns {object} 200 - The template record
|
||||||
|
* @returns {object} 404 - { error: 'Template not found' }
|
||||||
|
* @returns {object} 500 - { error: 'Internal server error' }
|
||||||
|
*/
|
||||||
|
router.get('/:id', requireAuth(), async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Template not found' });
|
||||||
|
}
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching template:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/archer-templates/:id
|
||||||
|
*
|
||||||
|
* Updates an existing template. Supports partial updates — only provided fields are changed.
|
||||||
|
* Always updates `updated_at` to NOW(). Requires Admin or Standard_User group.
|
||||||
|
*
|
||||||
|
* @param {number} id - The template ID to update
|
||||||
|
* @body {string} [vendor] - New vendor name, 1-100 chars after trim
|
||||||
|
* @body {string} [platform] - New platform name, 1-100 chars after trim
|
||||||
|
* @body {string} [model] - New model name, 1-100 chars after trim
|
||||||
|
* @body {string} [environment_overview] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [segmentation] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [mitigating_controls] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [additional_info] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [charter_network_banner] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [data_classification] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [charter_network] - Section content, max 10,000 chars
|
||||||
|
* @body {string} [additional_access_list] - Section content, max 10,000 chars
|
||||||
|
* @returns {object} 200 - The updated template record
|
||||||
|
* @returns {object} 400 - { error: 'validation message' }
|
||||||
|
* @returns {object} 404 - { error: 'Template not found' }
|
||||||
|
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
||||||
|
* @returns {object} 500 - { error: 'Internal server error' }
|
||||||
|
*/
|
||||||
|
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify template exists
|
||||||
|
const { rows: existingRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||||
|
if (existingRows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Template not found' });
|
||||||
|
}
|
||||||
|
const existing = existingRows[0];
|
||||||
|
|
||||||
|
// Validate provided hierarchy fields
|
||||||
|
const errors = [];
|
||||||
|
const updatedFields = {};
|
||||||
|
const changedFieldNames = [];
|
||||||
|
|
||||||
|
for (const field of ['vendor', 'platform', 'model']) {
|
||||||
|
const value = req.body[field];
|
||||||
|
if (value !== undefined) {
|
||||||
|
if (value === null || typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
errors.push(`${field} is required`);
|
||||||
|
} else if (value.trim().length > 100) {
|
||||||
|
errors.push(`${field} must be 100 characters or fewer`);
|
||||||
|
} else {
|
||||||
|
updatedFields[field] = value.trim();
|
||||||
|
if (value.trim() !== existing[field]) {
|
||||||
|
changedFieldNames.push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate provided section fields
|
||||||
|
for (const field of SECTION_FIELDS) {
|
||||||
|
const val = req.body[field];
|
||||||
|
if (val !== undefined) {
|
||||||
|
if (val !== null && typeof val === 'string') {
|
||||||
|
if (val.length > SECTION_MAX_LENGTH) {
|
||||||
|
errors.push(`${field} must be 10,000 characters or fewer`);
|
||||||
|
} else {
|
||||||
|
updatedFields[field] = val;
|
||||||
|
if (val !== existing[field]) {
|
||||||
|
changedFieldNames.push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updatedFields[field] = '';
|
||||||
|
if ('' !== existing[field]) {
|
||||||
|
changedFieldNames.push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return res.status(400).json({ error: errors.join('; ') });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check uniqueness if vendor/platform/model changed (excluding self)
|
||||||
|
const newVendor = updatedFields.vendor || existing.vendor;
|
||||||
|
const newPlatform = updatedFields.platform || existing.platform;
|
||||||
|
const newModel = updatedFields.model || existing.model;
|
||||||
|
|
||||||
|
if (updatedFields.vendor !== undefined || updatedFields.platform !== undefined || updatedFields.model !== undefined) {
|
||||||
|
const { rows: conflictRows } = await pool.query(
|
||||||
|
`SELECT id FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) AND LOWER(TRIM(platform)) = LOWER(TRIM($2)) AND LOWER(TRIM(model)) = LOWER(TRIM($3)) AND id != $4`,
|
||||||
|
[newVendor, newPlatform, newModel, id]
|
||||||
|
);
|
||||||
|
if (conflictRows.length > 0) {
|
||||||
|
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic UPDATE SET clause for only provided fields
|
||||||
|
const setClauses = [];
|
||||||
|
const params = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
for (const [field, value] of Object.entries(updatedFields)) {
|
||||||
|
setClauses.push(`${field} = $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always set updated_at = NOW()
|
||||||
|
setClauses.push(`updated_at = NOW()`);
|
||||||
|
|
||||||
|
// Execute update
|
||||||
|
params.push(id);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE archer_templates SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fire-and-forget audit log
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'template_updated',
|
||||||
|
entityType: 'archer_template',
|
||||||
|
entityId: String(id),
|
||||||
|
details: { changedFields: changedFieldNames },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating template:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/archer-templates/:id
|
||||||
|
*
|
||||||
|
* Permanently deletes a template. Requires Admin or Standard_User group.
|
||||||
|
*
|
||||||
|
* @param {number} id - The template ID to delete
|
||||||
|
* @returns {object} 200 - { message: 'Template deleted successfully' }
|
||||||
|
* @returns {object} 404 - { error: 'Template not found' }
|
||||||
|
* @returns {object} 500 - { error: 'Internal server error' }
|
||||||
|
*/
|
||||||
|
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify template exists
|
||||||
|
const { rows: existingRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||||
|
if (existingRows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Template not found' });
|
||||||
|
}
|
||||||
|
const existing = existingRows[0];
|
||||||
|
|
||||||
|
// Delete the template
|
||||||
|
await pool.query('DELETE FROM archer_templates WHERE id = $1', [id]);
|
||||||
|
|
||||||
|
// Fire-and-forget audit log
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'template_deleted',
|
||||||
|
entityType: 'archer_template',
|
||||||
|
entityId: String(id),
|
||||||
|
details: { vendor: existing.vendor, platform: existing.platform, model: existing.model },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Template deleted successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting template:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createArcherTemplatesRouter;
|
||||||
@@ -26,6 +26,7 @@ const logAudit = require('./helpers/auditLog');
|
|||||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||||
|
const createArcherTemplatesRouter = require('./routes/archerTemplates');
|
||||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
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)
|
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
||||||
app.use('/api/archer-tickets', createArcherTicketsRouter());
|
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)
|
// Ivanti / RiskSense workflow routes (all authenticated users)
|
||||||
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter());
|
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter());
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import JiraPage from './components/pages/JiraPage';
|
|||||||
import AdminPage from './components/pages/AdminPage';
|
import AdminPage from './components/pages/AdminPage';
|
||||||
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
||||||
import ArcherPage from './components/pages/ArcherPage';
|
import ArcherPage from './components/pages/ArcherPage';
|
||||||
|
import ArcherTemplatePage from './components/pages/ArcherTemplatePage';
|
||||||
import FeedbackModal from './components/FeedbackModal';
|
import FeedbackModal from './components/FeedbackModal';
|
||||||
import NotificationBell from './components/NotificationBell';
|
import NotificationBell from './components/NotificationBell';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@@ -199,7 +200,7 @@ export default function App() {
|
|||||||
const [cveDocuments, setCveDocuments] = useState({});
|
const [cveDocuments, setCveDocuments] = useState({});
|
||||||
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
||||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
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(() => {
|
const [currentPage, setCurrentPageRaw] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('cve-dashboard-page');
|
const saved = localStorage.getItem('cve-dashboard-page');
|
||||||
@@ -1105,6 +1106,7 @@ export default function App() {
|
|||||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
{currentPage === 'exports' && <ExportsPage />}
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
{currentPage === 'jira' && <JiraPage />}
|
{currentPage === 'jira' && <JiraPage />}
|
||||||
|
{currentPage === 'archer-templates' && <ArcherTemplatePage />}
|
||||||
{currentPage === 'admin' && isAdmin() && <AdminPage />}
|
{currentPage === 'admin' && isAdmin() && <AdminPage />}
|
||||||
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
|
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
|
||||||
|
|
||||||
|
|||||||
271
frontend/src/components/DeleteConfirmModal.js
Normal file
271
frontend/src/components/DeleteConfirmModal.js
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
// DeleteConfirmModal.js
|
||||||
|
// Confirmation dialog for deleting Archer templates.
|
||||||
|
// Identifies the template by vendor/platform/model before deletion.
|
||||||
|
// On confirm: calls DELETE API, invokes onConfirm callback, closes.
|
||||||
|
// On cancel: dismisses dialog, leaves template unchanged.
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeleteConfirmModal — confirmation dialog for deleting an Archer template.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* template {object|null} The template to delete (contains id, vendor, platform, model).
|
||||||
|
* When null/undefined, modal is hidden.
|
||||||
|
* onConfirm {function} Callback after successful delete (refresh list).
|
||||||
|
* onCancel {function} Callback to close without deleting.
|
||||||
|
*/
|
||||||
|
export default function DeleteConfirmModal({ template, onConfirm, onCancel }) {
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const cancelRef = useRef(null);
|
||||||
|
|
||||||
|
// Focus cancel button on open and handle Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
if (!template) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => cancelRef.current?.focus(), 50);
|
||||||
|
|
||||||
|
const handleKey = (e) => {
|
||||||
|
if (e.key === 'Escape' && !deleting) onCancel?.();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('keydown', handleKey);
|
||||||
|
};
|
||||||
|
}, [template, deleting, onCancel]);
|
||||||
|
|
||||||
|
// Reset state when template changes (new modal open)
|
||||||
|
useEffect(() => {
|
||||||
|
if (template) {
|
||||||
|
setDeleting(false);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [template]);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
if (!template) return;
|
||||||
|
setDeleting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/archer-templates/${template.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Delete failed (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirm?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}, [template, onConfirm]);
|
||||||
|
|
||||||
|
if (!template) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="delete-confirm-title"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 70,
|
||||||
|
background: 'rgba(10, 14, 39, 0.95)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '1rem',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !deleting) onCancel?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(239,68,68,0.06)',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '440px',
|
||||||
|
padding: '1.75rem 2rem',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.625rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
background: 'rgba(239, 68, 68, 0.10)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<AlertTriangle style={{ width: '16px', height: '16px', color: '#EF4444' }} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="delete-confirm-title"
|
||||||
|
style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#EF4444',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Template
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
color: '#CBD5E1',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
marginBottom: '1.25rem',
|
||||||
|
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||||
|
}}>
|
||||||
|
<p style={{ margin: '0 0 0.75rem 0' }}>
|
||||||
|
Are you sure you want to delete this template? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(239, 68, 68, 0.06)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.15)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||||
|
<span style={{ color: '#94A3B8', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Vendor
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#e0e0e0', fontSize: '0.82rem', fontWeight: 600 }}>
|
||||||
|
{template.vendor}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#94A3B8', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: '0.3rem' }}>
|
||||||
|
Platform
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#e0e0e0', fontSize: '0.82rem', fontWeight: 600 }}>
|
||||||
|
{template.platform}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#94A3B8', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: '0.3rem' }}>
|
||||||
|
Model
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#e0e0e0', fontSize: '0.82rem', fontWeight: 600 }}>
|
||||||
|
{template.model}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.6rem 0.75rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||||
|
color: '#FCA5A5',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
}}>
|
||||||
|
<AlertTriangle style={{ width: '12px', height: '12px', flexShrink: 0 }} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
ref={cancelRef}
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '0.625rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid rgba(100,116,139,0.4)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: '#94A3B8',
|
||||||
|
cursor: deleting ? 'not-allowed' : 'pointer',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
opacity: deleting ? 0.5 : 1,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
if (!deleting) {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)';
|
||||||
|
e.currentTarget.style.color = '#CBD5E1';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)';
|
||||||
|
e.currentTarget.style.color = '#94A3B8';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1.5,
|
||||||
|
padding: '0.625rem',
|
||||||
|
background: 'rgba(239, 68, 68, 0.10)',
|
||||||
|
border: '1px solid #EF4444',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: '#EF4444',
|
||||||
|
cursor: deleting ? 'not-allowed' : 'pointer',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
opacity: deleting ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
if (!deleting) {
|
||||||
|
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.18)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 0 20px rgba(239,68,68,0.15)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.10)';
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 style={{ width: '13px', height: '13px' }} />
|
||||||
|
{deleting ? 'Deleting...' : 'Delete Template'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import 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';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
|
|||||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
{ 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: '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: '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' };
|
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
||||||
|
|||||||
523
frontend/src/components/TemplateFormModal.js
Normal file
523
frontend/src/components/TemplateFormModal.js
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
// 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';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}
|
||||||
|
>
|
||||||
|
<div style={STYLES.modal}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={STYLES.header}>
|
||||||
|
<span id="template-form-modal-title" style={STYLES.title}>
|
||||||
|
{titles[mode] || 'Template'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
style={STYLES.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API error banner */}
|
||||||
|
{apiError && (
|
||||||
|
<div style={STYLES.errorBanner}>
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
{apiError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
|
{/* Hierarchy fields */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.75rem' }}>
|
||||||
|
{/* Vendor */}
|
||||||
|
<div style={STYLES.fieldGroup}>
|
||||||
|
<label style={STYLES.label} htmlFor="tmpl-vendor">Vendor *</label>
|
||||||
|
<input
|
||||||
|
ref={vendorRef}
|
||||||
|
id="tmpl-vendor"
|
||||||
|
type="text"
|
||||||
|
maxLength={100}
|
||||||
|
value={vendor}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVendor(e.target.value);
|
||||||
|
if (fieldErrors.vendor) setFieldErrors(prev => ({ ...prev, vendor: undefined }));
|
||||||
|
}}
|
||||||
|
style={{ ...STYLES.input, ...(fieldErrors.vendor ? STYLES.inputError : {}) }}
|
||||||
|
placeholder="e.g. Harmonic"
|
||||||
|
/>
|
||||||
|
{fieldErrors.vendor && <div style={STYLES.errorText}>{fieldErrors.vendor}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform */}
|
||||||
|
<div style={STYLES.fieldGroup}>
|
||||||
|
<label style={STYLES.label} htmlFor="tmpl-platform">Platform *</label>
|
||||||
|
<input
|
||||||
|
id="tmpl-platform"
|
||||||
|
type="text"
|
||||||
|
maxLength={100}
|
||||||
|
value={platform}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPlatform(e.target.value);
|
||||||
|
if (fieldErrors.platform) setFieldErrors(prev => ({ ...prev, platform: undefined }));
|
||||||
|
}}
|
||||||
|
style={{ ...STYLES.input, ...(fieldErrors.platform ? STYLES.inputError : {}) }}
|
||||||
|
placeholder="e.g. vCMTS"
|
||||||
|
/>
|
||||||
|
{fieldErrors.platform && <div style={STYLES.errorText}>{fieldErrors.platform}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model */}
|
||||||
|
<div style={STYLES.fieldGroup}>
|
||||||
|
<label style={STYLES.label} htmlFor="tmpl-model">Model *</label>
|
||||||
|
<input
|
||||||
|
id="tmpl-model"
|
||||||
|
type="text"
|
||||||
|
maxLength={100}
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => {
|
||||||
|
setModel(e.target.value);
|
||||||
|
if (fieldErrors.model) setFieldErrors(prev => ({ ...prev, model: undefined }));
|
||||||
|
}}
|
||||||
|
style={{ ...STYLES.input, ...(fieldErrors.model ? STYLES.inputError : {}) }}
|
||||||
|
placeholder="e.g. 3.29.1"
|
||||||
|
/>
|
||||||
|
{fieldErrors.model && <div style={STYLES.errorText}>{fieldErrors.model}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section textareas */}
|
||||||
|
<div style={STYLES.sectionDivider}>Template Sections</div>
|
||||||
|
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<div key={section.key} style={STYLES.fieldGroup}>
|
||||||
|
<label style={STYLES.label} htmlFor={`tmpl-${section.key}`}>
|
||||||
|
{section.label}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={`tmpl-${section.key}`}
|
||||||
|
value={sections[section.key]}
|
||||||
|
onChange={(e) => handleSectionChange(section.key, e.target.value)}
|
||||||
|
maxLength={10000}
|
||||||
|
style={STYLES.textarea}
|
||||||
|
placeholder={`Enter ${section.label.toLowerCase()} content...`}
|
||||||
|
/>
|
||||||
|
{sections[section.key].length > 9500 && (
|
||||||
|
<div style={STYLES.charCount}>
|
||||||
|
{sections[section.key].length}/10,000
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
<div style={STYLES.footer}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={STYLES.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
style={{
|
||||||
|
...STYLES.submitBtn,
|
||||||
|
...(submitting ? STYLES.submitBtnDisabled : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? <Loader size={13} /> : <Save size={13} />}
|
||||||
|
{submitting ? 'Saving...' : (mode === 'edit' ? 'Update Template' : 'Save Template')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
621
frontend/src/components/TemplateSelector.js
Normal file
621
frontend/src/components/TemplateSelector.js
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Search, ChevronDown, Loader, FileText, Clipboard, Check, Copy } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Section field mapping — ordered: static first, then semi-static
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const SECTIONS = [
|
||||||
|
// Static sections
|
||||||
|
{ key: 'environment_overview', label: 'Environment Overview' },
|
||||||
|
{ key: 'segmentation', label: 'Segmentation' },
|
||||||
|
{ key: 'mitigating_controls', label: 'Mitigating Controls' },
|
||||||
|
// Semi-static sections
|
||||||
|
{ key: 'additional_info', label: 'Additional Info/Background' },
|
||||||
|
{ key: 'charter_network_banner', label: 'Charter Network Banner' },
|
||||||
|
{ key: 'data_classification', label: 'Data Classification' },
|
||||||
|
{ key: 'charter_network', label: 'Charter Network' },
|
||||||
|
{ key: 'additional_access_list', label: 'Additional Access List' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Styles — dark theme tactical intelligence aesthetic
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const STYLES = {
|
||||||
|
container: {
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#00d4ff',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
},
|
||||||
|
searchWrapper: {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
searchIcon: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0.75rem',
|
||||||
|
color: '#64748b',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.625rem 2.25rem 0.625rem 2.25rem',
|
||||||
|
background: 'rgba(15, 23, 42, 0.9)',
|
||||||
|
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||||
|
},
|
||||||
|
inputFocused: {
|
||||||
|
borderColor: 'rgba(0, 212, 255, 0.5)',
|
||||||
|
boxShadow: '0 0 12px rgba(0, 212, 255, 0.1)',
|
||||||
|
},
|
||||||
|
chevron: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: '0.75rem',
|
||||||
|
color: '#64748b',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
},
|
||||||
|
chevronOpen: {
|
||||||
|
transform: 'rotate(180deg)',
|
||||||
|
},
|
||||||
|
dropdown: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
marginTop: '4px',
|
||||||
|
background: 'linear-gradient(135deg, rgba(22, 33, 62, 0.98), rgba(15, 23, 42, 0.99))',
|
||||||
|
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxHeight: '240px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
zIndex: 50,
|
||||||
|
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.6)',
|
||||||
|
},
|
||||||
|
dropdownItem: {
|
||||||
|
padding: '0.6rem 0.875rem',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
borderBottom: '1px solid rgba(100, 116, 139, 0.1)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
},
|
||||||
|
dropdownItemHover: {
|
||||||
|
background: 'rgba(0, 212, 255, 0.08)',
|
||||||
|
},
|
||||||
|
dropdownItemSelected: {
|
||||||
|
background: 'rgba(0, 212, 255, 0.12)',
|
||||||
|
color: '#00d4ff',
|
||||||
|
},
|
||||||
|
loadingState: {
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
color: '#64748b',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
padding: '1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#64748b',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||||
|
},
|
||||||
|
selectedDisplay: {
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: 'rgba(0, 212, 255, 0.06)',
|
||||||
|
border: '1px solid rgba(0, 212, 255, 0.15)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#00d4ff',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
},
|
||||||
|
// Section panel styles
|
||||||
|
sectionPanel: {
|
||||||
|
marginTop: '1rem',
|
||||||
|
background: 'linear-gradient(135deg, rgba(22, 33, 62, 0.6), rgba(15, 23, 42, 0.8))',
|
||||||
|
border: '1px solid rgba(0, 212, 255, 0.15)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '1rem',
|
||||||
|
},
|
||||||
|
sectionPanelHeader: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
paddingBottom: '0.5rem',
|
||||||
|
borderBottom: '1px solid rgba(100, 116, 139, 0.2)',
|
||||||
|
},
|
||||||
|
sectionPanelTitle: {
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#00d4ff',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
},
|
||||||
|
copyAllButton: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.35rem',
|
||||||
|
padding: '0.35rem 0.65rem',
|
||||||
|
background: 'rgba(0, 212, 255, 0.1)',
|
||||||
|
border: '1px solid rgba(0, 212, 255, 0.3)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#00d4ff',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s, border-color 0.2s',
|
||||||
|
},
|
||||||
|
copyAllButtonHover: {
|
||||||
|
background: 'rgba(0, 212, 255, 0.18)',
|
||||||
|
borderColor: 'rgba(0, 212, 255, 0.5)',
|
||||||
|
},
|
||||||
|
copyAllButtonCopied: {
|
||||||
|
background: 'rgba(34, 197, 94, 0.15)',
|
||||||
|
borderColor: 'rgba(34, 197, 94, 0.4)',
|
||||||
|
color: '#22c55e',
|
||||||
|
},
|
||||||
|
sectionBlock: {
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
padding: '0.6rem 0.75rem',
|
||||||
|
background: 'rgba(15, 23, 42, 0.5)',
|
||||||
|
border: '1px solid rgba(100, 116, 139, 0.15)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
},
|
||||||
|
sectionBlockHeader: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '0.35rem',
|
||||||
|
},
|
||||||
|
sectionLabel: {
|
||||||
|
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#94a3b8',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
},
|
||||||
|
sectionContent: {
|
||||||
|
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
maxHeight: '120px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
},
|
||||||
|
sectionEmpty: {
|
||||||
|
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: '#64748b',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
copyButton: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: 'rgba(100, 116, 139, 0.15)',
|
||||||
|
border: '1px solid rgba(100, 116, 139, 0.25)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s, color 0.2s, border-color 0.2s',
|
||||||
|
},
|
||||||
|
copyButtonHover: {
|
||||||
|
background: 'rgba(0, 212, 255, 0.1)',
|
||||||
|
borderColor: 'rgba(0, 212, 255, 0.3)',
|
||||||
|
color: '#00d4ff',
|
||||||
|
},
|
||||||
|
copyButtonCopied: {
|
||||||
|
background: 'rgba(34, 197, 94, 0.12)',
|
||||||
|
borderColor: 'rgba(34, 197, 94, 0.3)',
|
||||||
|
color: '#22c55e',
|
||||||
|
},
|
||||||
|
copyButtonDisabled: {
|
||||||
|
opacity: 0.4,
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TemplateSelector — searchable dropdown for selecting Archer templates.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* onSelect {function} — optional callback invoked with the full template object when a selection is made
|
||||||
|
*/
|
||||||
|
export default function TemplateSelector({ onSelect }) {
|
||||||
|
const [templates, setTemplates] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||||
|
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
||||||
|
const [inputFocused, setInputFocused] = useState(false);
|
||||||
|
|
||||||
|
// Copy state: per-section copied confirmation + copy all
|
||||||
|
const [copiedSections, setCopiedSections] = useState({});
|
||||||
|
const [copyAllCopied, setCopyAllCopied] = useState(false);
|
||||||
|
const [copyAllHovered, setCopyAllHovered] = useState(false);
|
||||||
|
const [hoveredCopyButton, setHoveredCopyButton] = useState(null);
|
||||||
|
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
// Fetch all templates on mount
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function fetchTemplates() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`${API_BASE}/archer-templates`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch templates (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!cancelled) {
|
||||||
|
setTemplates(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchTemplates();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Client-side filter — case-insensitive substring match on vendor, platform, or model
|
||||||
|
const filteredTemplates = useCallback(() => {
|
||||||
|
if (!searchText.trim()) return templates;
|
||||||
|
const query = searchText.toLowerCase().trim();
|
||||||
|
return templates.filter(t =>
|
||||||
|
t.vendor.toLowerCase().includes(query) ||
|
||||||
|
t.platform.toLowerCase().includes(query) ||
|
||||||
|
t.model.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [templates, searchText])();
|
||||||
|
|
||||||
|
// Handle template selection
|
||||||
|
const handleSelect = (template) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setSearchText(`${template.vendor} / ${template.platform} / ${template.model}`);
|
||||||
|
setIsOpen(false);
|
||||||
|
setCopiedSections({});
|
||||||
|
setCopyAllCopied(false);
|
||||||
|
if (onSelect) {
|
||||||
|
onSelect(template);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle input change
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
setSearchText(e.target.value);
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
setIsOpen(true);
|
||||||
|
setHoveredIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle input focus
|
||||||
|
const handleInputFocus = () => {
|
||||||
|
setInputFocused(true);
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle input blur
|
||||||
|
const handleInputBlur = () => {
|
||||||
|
setInputFocused(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||||
|
setIsOpen(true);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setHoveredIndex(prev =>
|
||||||
|
prev < filteredTemplates.length - 1 ? prev + 1 : 0
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setHoveredIndex(prev =>
|
||||||
|
prev > 0 ? prev - 1 : filteredTemplates.length - 1
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (hoveredIndex >= 0 && hoveredIndex < filteredTemplates.length) {
|
||||||
|
handleSelect(filteredTemplates[hoveredIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy a single section to clipboard
|
||||||
|
const handleCopySection = async (sectionKey, content) => {
|
||||||
|
if (!content) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
setCopiedSections(prev => ({ ...prev, [sectionKey]: true }));
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopiedSections(prev => ({ ...prev, [sectionKey]: false }));
|
||||||
|
}, 2000);
|
||||||
|
} catch (_err) {
|
||||||
|
// Clipboard API failed — silently ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy All: concatenate non-empty sections with headers
|
||||||
|
const handleCopyAll = async () => {
|
||||||
|
if (!selectedTemplate) return;
|
||||||
|
const parts = [];
|
||||||
|
for (const section of SECTIONS) {
|
||||||
|
const content = selectedTemplate[section.key];
|
||||||
|
if (content && content.trim()) {
|
||||||
|
parts.push(`${section.label}\n${content}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const combined = parts.join('\n\n');
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(combined);
|
||||||
|
setCopyAllCopied(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopyAllCopied(false);
|
||||||
|
}, 2000);
|
||||||
|
} catch (_err) {
|
||||||
|
// Clipboard API failed — silently ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if there are any non-empty sections
|
||||||
|
const hasNonEmptySections = selectedTemplate && SECTIONS.some(s => {
|
||||||
|
const val = selectedTemplate[s.key];
|
||||||
|
return val && val.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={STYLES.container} ref={containerRef}>
|
||||||
|
{/* Label */}
|
||||||
|
<div style={STYLES.label}>
|
||||||
|
<FileText size={12} />
|
||||||
|
Template Selector
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input with dropdown */}
|
||||||
|
<div style={STYLES.searchWrapper}>
|
||||||
|
<Search size={14} style={STYLES.searchIcon} />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder={loading ? 'Loading templates...' : 'Search by vendor, platform, or model...'}
|
||||||
|
value={searchText}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
...STYLES.input,
|
||||||
|
...(inputFocused ? STYLES.inputFocused : {}),
|
||||||
|
opacity: loading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
aria-label="Search templates"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
role="combobox"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
/>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
...STYLES.chevron,
|
||||||
|
...(isOpen ? STYLES.chevronOpen : {}),
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown list */}
|
||||||
|
{isOpen && (
|
||||||
|
<div style={STYLES.dropdown} role="listbox" aria-label="Template list">
|
||||||
|
{loading ? (
|
||||||
|
<div style={STYLES.loadingState}>
|
||||||
|
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
Loading templates...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ ...STYLES.emptyState, color: '#ef4444' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : filteredTemplates.length === 0 ? (
|
||||||
|
<div style={STYLES.emptyState}>
|
||||||
|
{searchText.trim()
|
||||||
|
? 'No templates match your search'
|
||||||
|
: 'No templates available'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTemplates.map((template, index) => {
|
||||||
|
const isSelected = selectedTemplate?.id === template.id;
|
||||||
|
const isHovered = hoveredIndex === index;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
style={{
|
||||||
|
...STYLES.dropdownItem,
|
||||||
|
...(isHovered ? STYLES.dropdownItemHover : {}),
|
||||||
|
...(isSelected ? STYLES.dropdownItemSelected : {}),
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredIndex(index)}
|
||||||
|
onMouseLeave={() => setHoveredIndex(-1)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // Prevent input blur before click registers
|
||||||
|
handleSelect(template);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
|
||||||
|
{template.vendor} / {template.platform} / {template.model}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section display panel — shown when a template is selected */}
|
||||||
|
{selectedTemplate && (
|
||||||
|
<div style={STYLES.sectionPanel}>
|
||||||
|
{/* Panel header with Copy All button */}
|
||||||
|
<div style={STYLES.sectionPanelHeader}>
|
||||||
|
<span style={STYLES.sectionPanelTitle}>Template Sections</span>
|
||||||
|
{hasNonEmptySections && (
|
||||||
|
<button
|
||||||
|
onClick={handleCopyAll}
|
||||||
|
onMouseEnter={() => setCopyAllHovered(true)}
|
||||||
|
onMouseLeave={() => setCopyAllHovered(false)}
|
||||||
|
style={{
|
||||||
|
...STYLES.copyAllButton,
|
||||||
|
...(copyAllCopied ? STYLES.copyAllButtonCopied : {}),
|
||||||
|
...(!copyAllCopied && copyAllHovered ? STYLES.copyAllButtonHover : {}),
|
||||||
|
}}
|
||||||
|
aria-label="Copy all sections"
|
||||||
|
>
|
||||||
|
{copyAllCopied ? (
|
||||||
|
<>
|
||||||
|
<Check size={11} />
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy size={11} />
|
||||||
|
Copy All
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section blocks */}
|
||||||
|
{SECTIONS.map((section) => {
|
||||||
|
const content = selectedTemplate[section.key];
|
||||||
|
const isEmpty = !content || !content.trim();
|
||||||
|
const isCopied = copiedSections[section.key];
|
||||||
|
const isButtonHovered = hoveredCopyButton === section.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={section.key} style={STYLES.sectionBlock}>
|
||||||
|
<div style={STYLES.sectionBlockHeader}>
|
||||||
|
<span style={STYLES.sectionLabel}>{section.label}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopySection(section.key, content)}
|
||||||
|
disabled={isEmpty}
|
||||||
|
onMouseEnter={() => setHoveredCopyButton(section.key)}
|
||||||
|
onMouseLeave={() => setHoveredCopyButton(null)}
|
||||||
|
style={{
|
||||||
|
...STYLES.copyButton,
|
||||||
|
...(isEmpty ? STYLES.copyButtonDisabled : {}),
|
||||||
|
...(isCopied ? STYLES.copyButtonCopied : {}),
|
||||||
|
...(!isEmpty && !isCopied && isButtonHovered ? STYLES.copyButtonHover : {}),
|
||||||
|
}}
|
||||||
|
aria-label={`Copy ${section.label}`}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<Check size={10} />
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Clipboard size={10} />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isEmpty ? (
|
||||||
|
<div style={STYLES.sectionEmpty}>No content stored</div>
|
||||||
|
) : (
|
||||||
|
<div style={STYLES.sectionContent}>{content}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
427
frontend/src/components/pages/ArcherTemplatePage.js
Normal file
427
frontend/src/components/pages/ArcherTemplatePage.js
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
// 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,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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}
|
||||||
|
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}>{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>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
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 { useAuth } from '../../contexts/AuthContext';
|
||||||
import ConsolidationModal from '../ConsolidationModal';
|
import ConsolidationModal from '../ConsolidationModal';
|
||||||
import LoaderModal from '../LoaderModal';
|
import LoaderModal from '../LoaderModal';
|
||||||
|
import TemplateSelector from '../TemplateSelector';
|
||||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
||||||
import { groupQueueItems } from '../../utils/queueGrouping';
|
import { groupQueueItems } from '../../utils/queueGrouping';
|
||||||
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
|
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)
|
// Collapse state for grouped sections (Requirement 2.2, 2.7)
|
||||||
const [collapsedSections, setCollapsedSections] = useState({});
|
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
|
// 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)
|
// Selection mode toggle (Requirement 1.1, 1.5)
|
||||||
// When deactivated, clear all selections
|
// When deactivated, clear all selections
|
||||||
@@ -727,151 +738,202 @@ export default function IvantiTodoQueuePage() {
|
|||||||
const cveDisplay = cves.length > 0
|
const cveDisplay = cves.length > 0
|
||||||
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
|
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
|
||||||
: '';
|
: '';
|
||||||
|
const isArcherItem = item.workflow_type === 'Archer';
|
||||||
|
const isTemplatePanelOpen = templatePanelOpenId === item.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<React.Fragment key={item.id}>
|
||||||
key={item.id}
|
<div
|
||||||
style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem}
|
style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem}
|
||||||
onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined}
|
onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined}
|
||||||
role={selectionMode ? 'button' : undefined}
|
role={selectionMode ? 'button' : undefined}
|
||||||
tabIndex={selectionMode ? 0 : undefined}
|
tabIndex={selectionMode ? 0 : undefined}
|
||||||
onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
|
onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
|
||||||
>
|
>
|
||||||
{/* Selection checkbox (Requirement 1.2) */}
|
{/* Selection checkbox (Requirement 1.2) */}
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={(e) => { e.stopPropagation(); toggleItemSelection(item.id); }}
|
onChange={(e) => { e.stopPropagation(); toggleItemSelection(item.id); }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={STYLES.checkbox}
|
style={STYLES.checkbox}
|
||||||
aria-label={`Select ${item.finding_title || item.finding_id}`}
|
aria-label={`Select ${item.finding_title || item.finding_id}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Finding info */}
|
{/* Finding info */}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<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 && (
|
|
||||||
<div style={{
|
<div style={{
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: '0.65rem',
|
fontSize: '0.75rem',
|
||||||
color: '#64748B',
|
fontWeight: 600,
|
||||||
marginTop: '2px',
|
color: '#CBD5E1',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}} title={cves.join(', ')}>
|
}} title={item.finding_title || item.finding_id}>
|
||||||
{cveDisplay}
|
{item.finding_title || item.finding_id}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{cveDisplay && (
|
||||||
</div>
|
<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) */}
|
{/* Archer Template toggle button (Requirement 5.1) */}
|
||||||
{ticketLinks[item.id] && (
|
{isArcherItem && (
|
||||||
<a
|
<button
|
||||||
href={ticketLinks[item.id].jira_url}
|
onClick={(e) => { e.stopPropagation(); toggleTemplatePanel(item.id); }}
|
||||||
target="_blank"
|
style={{
|
||||||
rel="noopener noreferrer"
|
display: 'inline-flex',
|
||||||
onClick={(e) => e.stopPropagation()}
|
alignItems: 'center',
|
||||||
style={{
|
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',
|
fontFamily: 'monospace',
|
||||||
fontSize: '0.6rem',
|
fontSize: '0.6rem',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#6EE7B7',
|
color: wfColor.col,
|
||||||
background: 'rgba(16, 185, 129, 0.1)',
|
background: `rgba(${wfColor.rgb}, 0.1)`,
|
||||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
border: `1px solid rgba(${wfColor.rgb}, 0.3)`,
|
||||||
borderRadius: '999px',
|
borderRadius: '4px',
|
||||||
padding: '0.15rem 0.5rem',
|
padding: '0.15rem 0.4rem',
|
||||||
textDecoration: 'none',
|
textTransform: 'uppercase',
|
||||||
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,
|
|
||||||
}}>
|
}}>
|
||||||
{item.ip_address}
|
{item.workflow_type}
|
||||||
</div>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user