From 35007878518bf210376a5eb555cb8aed49496478 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Tue, 2 Jun 2026 16:08:25 -0600 Subject: [PATCH] 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 --- .../migrations/add_archer_templates_table.js | 60 ++ backend/migrations/run-all.js | 1 + backend/routes/archerTemplates.js | 543 +++++++++++++++ backend/server.js | 4 + frontend/src/App.js | 4 +- frontend/src/components/DeleteConfirmModal.js | 271 ++++++++ frontend/src/components/NavDrawer.js | 3 +- frontend/src/components/TemplateFormModal.js | 523 +++++++++++++++ frontend/src/components/TemplateSelector.js | 621 ++++++++++++++++++ .../components/pages/ArcherTemplatePage.js | 427 ++++++++++++ .../components/pages/IvantiTodoQueuePage.js | 324 +++++---- 11 files changed, 2648 insertions(+), 133 deletions(-) create mode 100644 backend/migrations/add_archer_templates_table.js create mode 100644 backend/routes/archerTemplates.js create mode 100644 frontend/src/components/DeleteConfirmModal.js create mode 100644 frontend/src/components/TemplateFormModal.js create mode 100644 frontend/src/components/TemplateSelector.js create mode 100644 frontend/src/components/pages/ArcherTemplatePage.js diff --git a/backend/migrations/add_archer_templates_table.js b/backend/migrations/add_archer_templates_table.js new file mode 100644 index 0000000..43960fd --- /dev/null +++ b/backend/migrations/add_archer_templates_table.js @@ -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)); +} diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index a025543..18d267f 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -26,6 +26,7 @@ const POSTGRES_MIGRATIONS = [ 'add_multi_item_jira_ticket.js', 'drop_jira_status_check_constraint.js', 'add_compliance_history_metric_id.js', + 'add_archer_templates_table.js', ]; async function runAll() { diff --git a/backend/routes/archerTemplates.js b/backend/routes/archerTemplates.js new file mode 100644 index 0000000..db47f49 --- /dev/null +++ b/backend/routes/archerTemplates.js @@ -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; diff --git a/backend/server.js b/backend/server.js index 38950c5..fb0eb84 100644 --- a/backend/server.js +++ b/backend/server.js @@ -26,6 +26,7 @@ const logAudit = require('./helpers/auditLog'); const createNvdLookupRouter = require('./routes/nvdLookup'); const createKnowledgeBaseRouter = require('./routes/knowledgeBase'); const createArcherTicketsRouter = require('./routes/archerTickets'); +const createArcherTemplatesRouter = require('./routes/archerTemplates'); const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows'); const createIvantiFindingsRouter = require('./routes/ivantiFindings'); const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue'); @@ -201,6 +202,9 @@ app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload)); // Archer tickets routes (editor/admin for create/update/delete, all authenticated for view) app.use('/api/archer-tickets', createArcherTicketsRouter()); +// Archer template library routes (editor/admin for create/update/delete/clone, all authenticated for view) +app.use('/api/archer-templates', createArcherTemplatesRouter()); + // Ivanti / RiskSense workflow routes (all authenticated users) app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter()); diff --git a/frontend/src/App.js b/frontend/src/App.js index 2834869..42446ab 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -19,6 +19,7 @@ import JiraPage from './components/pages/JiraPage'; import AdminPage from './components/pages/AdminPage'; import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar'; import ArcherPage from './components/pages/ArcherPage'; +import ArcherTemplatePage from './components/pages/ArcherTemplatePage'; import FeedbackModal from './components/FeedbackModal'; import NotificationBell from './components/NotificationBell'; import './App.css'; @@ -199,7 +200,7 @@ export default function App() { const [cveDocuments, setCveDocuments] = useState({}); const [quickCheckCVE, setQuickCheckCVE] = useState(''); const [quickCheckResult, setQuickCheckResult] = useState(null); - const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin']); + const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin', 'archer-templates']); const [currentPage, setCurrentPageRaw] = useState(() => { try { const saved = localStorage.getItem('cve-dashboard-page'); @@ -1105,6 +1106,7 @@ export default function App() { {currentPage === 'knowledge-base' && } {currentPage === 'exports' && } {currentPage === 'jira' && } + {currentPage === 'archer-templates' && } {currentPage === 'admin' && isAdmin() && } {currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()} diff --git a/frontend/src/components/DeleteConfirmModal.js b/frontend/src/components/DeleteConfirmModal.js new file mode 100644 index 0000000..7e88b0b --- /dev/null +++ b/frontend/src/components/DeleteConfirmModal.js @@ -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 ( +
{ + if (e.target === e.currentTarget && !deleting) onCancel?.(); + }} + > +
+ {/* Header */} +
+
+ +
+
+ Delete Template +
+
+ + {/* Body */} +
+

+ Are you sure you want to delete this template? This action cannot be undone. +

+
+
+ + Vendor + + + {template.vendor} + + + Platform + + + {template.platform} + + + Model + + + {template.model} + +
+
+
+ + {/* Error banner */} + {error && ( +
+ + {error} +
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/NavDrawer.js b/frontend/src/components/NavDrawer.js index 16186fe..695266f 100644 --- a/frontend/src/components/NavDrawer.js +++ b/frontend/src/components/NavDrawer.js @@ -1,5 +1,5 @@ import React from 'react'; -import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2 } from 'lucide-react'; +import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2, Layers } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; const NAV_ITEMS = [ @@ -10,6 +10,7 @@ const NAV_ITEMS = [ { id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' }, { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' }, { id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' }, + { id: 'archer-templates', label: 'Template Mgr', icon: Layers, color: '#F472B6', description: 'Archer template library' }, ]; const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' }; diff --git a/frontend/src/components/TemplateFormModal.js b/frontend/src/components/TemplateFormModal.js new file mode 100644 index 0000000..db321b8 --- /dev/null +++ b/frontend/src/components/TemplateFormModal.js @@ -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 ( +
{ if (e.target === e.currentTarget) onClose?.(); }} + > +
+ {/* Header */} +
+ + {titles[mode] || 'Template'} + + +
+ + {/* API error banner */} + {apiError && ( +
+ + {apiError} +
+ )} + +
+ {/* Hierarchy fields */} +
+ {/* Vendor */} +
+ + { + 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 &&
{fieldErrors.vendor}
} +
+ + {/* Platform */} +
+ + { + 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 &&
{fieldErrors.platform}
} +
+ + {/* Model */} +
+ + { + 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 &&
{fieldErrors.model}
} +
+
+ + {/* Section textareas */} +
Template Sections
+ + {SECTIONS.map((section) => ( +
+ +