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 && (
+
+ )}
+
+ {/* 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 && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/TemplateSelector.js b/frontend/src/components/TemplateSelector.js
new file mode 100644
index 0000000..523e450
--- /dev/null
+++ b/frontend/src/components/TemplateSelector.js
@@ -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 (
+
+ {/* Label */}
+
+
+ Template Selector
+
+
+ {/* Search input with dropdown */}
+
+
+
+ {
+ setIsOpen(!isOpen);
+ inputRef.current?.focus();
+ }}
+ />
+
+
+ {/* Dropdown list */}
+ {isOpen && (
+
+ {loading ? (
+
+
+ Loading templates...
+
+ ) : error ? (
+
+ {error}
+
+ ) : filteredTemplates.length === 0 ? (
+
+ {searchText.trim()
+ ? 'No templates match your search'
+ : 'No templates available'}
+
+ ) : (
+ filteredTemplates.map((template, index) => {
+ const isSelected = selectedTemplate?.id === template.id;
+ const isHovered = hoveredIndex === index;
+ return (
+
setHoveredIndex(index)}
+ onMouseLeave={() => setHoveredIndex(-1)}
+ onMouseDown={(e) => {
+ e.preventDefault(); // Prevent input blur before click registers
+ handleSelect(template);
+ }}
+ >
+
+ {template.vendor} / {template.platform} / {template.model}
+
+ );
+ })
+ )}
+
+ )}
+
+ {/* Section display panel — shown when a template is selected */}
+ {selectedTemplate && (
+
+ {/* Panel header with Copy All button */}
+
+ Template Sections
+ {hasNonEmptySections && (
+
+ )}
+
+
+ {/* 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 (
+
+
+ {section.label}
+
+
+ {isEmpty ? (
+
No content stored
+ ) : (
+
{content}
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/pages/ArcherTemplatePage.js b/frontend/src/components/pages/ArcherTemplatePage.js
new file mode 100644
index 0000000..af026cc
--- /dev/null
+++ b/frontend/src/components/pages/ArcherTemplatePage.js
@@ -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 (
+
+
+ {/* Header */}
+
+
+ Archer Template Library
+
+
+ {/* Toolbar */}
+
+
+
+ {totalCount} template{totalCount !== 1 ? 's' : ''}
+
+
+
+
+ {canWrite() && (
+
+ )}
+
+
+ {/* Error banner */}
+ {error && (
+
+ )}
+
+ {/* Loading state */}
+ {loading && (
+
+
+ Loading templates...
+
+ )}
+
+ {/* Empty state */}
+ {!loading && !error && templates.length === 0 && (
+
+
+
No templates found
+ {canWrite() && (
+
+ Click "Create Template" to add your first template.
+
+ )}
+
+ )}
+
+ {/* Template list grouped by vendor > platform */}
+ {!loading && grouped.map(({ vendor, platforms }) => (
+
+ {/* Vendor header — collapsible */}
+
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]
+ ?
+ :
+ }
+ {vendor}
+
+ {platforms.reduce((sum, p) => sum + p.templates.length, 0)} template{platforms.reduce((sum, p) => sum + p.templates.length, 0) !== 1 ? 's' : ''}
+
+
+
+ {/* Platform subgroups */}
+ {expandedVendors[vendor] && platforms.map(({ platform, templates: platTemplates }) => (
+
+
{platform}
+ {platTemplates.map(template => (
+
{ e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }}
+ onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
+ >
+
{template.model}
+ {canWrite() && (
+
+
+
+
+
+ )}
+
+ ))}
+
+ ))}
+
+ ))}
+
+
+ {/* Template form modal (create/edit/clone) */}
+ {modalState.open && (
+
setModalState({ open: false, mode: 'create', template: null })}
+ onSuccess={fetchTemplates}
+ />
+ )}
+
+ {/* Delete confirmation modal */}
+ {
+ setDeleteTarget(null);
+ fetchTemplates();
+ }}
+ onCancel={() => setDeleteTarget(null)}
+ />
+
+ );
+}
diff --git a/frontend/src/components/pages/IvantiTodoQueuePage.js b/frontend/src/components/pages/IvantiTodoQueuePage.js
index 639a880..46c3d23 100644
--- a/frontend/src/components/pages/IvantiTodoQueuePage.js
+++ b/frontend/src/components/pages/IvantiTodoQueuePage.js
@@ -1,8 +1,9 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
-import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet } from 'lucide-react';
+import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet, FileText } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import ConsolidationModal from '../ConsolidationModal';
import LoaderModal from '../LoaderModal';
+import TemplateSelector from '../TemplateSelector';
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
import { groupQueueItems } from '../../utils/queueGrouping';
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
@@ -311,6 +312,9 @@ export default function IvantiTodoQueuePage() {
// Collapse state for grouped sections (Requirement 2.2, 2.7)
const [collapsedSections, setCollapsedSections] = useState({});
+ // Archer Template Selector panel — tracks which item ID has the panel expanded (Requirement 5.1)
+ const [templatePanelOpenId, setTemplatePanelOpenId] = useState(null);
+
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
@@ -384,6 +388,13 @@ export default function IvantiTodoQueuePage() {
}));
}, []);
+ // ---------------------------------------------------------------------------
+ // Toggle Archer Template Selector panel (Requirement 5.1)
+ // ---------------------------------------------------------------------------
+ const toggleTemplatePanel = useCallback((itemId) => {
+ setTemplatePanelOpenId((prev) => (prev === itemId ? null : itemId));
+ }, []);
+
// ---------------------------------------------------------------------------
// Selection mode toggle (Requirement 1.1, 1.5)
// When deactivated, clear all selections
@@ -727,151 +738,202 @@ export default function IvantiTodoQueuePage() {
const cveDisplay = cves.length > 0
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
: '';
+ const isArcherItem = item.workflow_type === 'Archer';
+ const isTemplatePanelOpen = templatePanelOpenId === item.id;
return (
- toggleItemSelection(item.id) : undefined}
- role={selectionMode ? 'button' : undefined}
- tabIndex={selectionMode ? 0 : undefined}
- onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
- >
- {/* Selection checkbox (Requirement 1.2) */}
- {selectionMode && (
-
{ e.stopPropagation(); toggleItemSelection(item.id); }}
- onClick={(e) => e.stopPropagation()}
- style={STYLES.checkbox}
- aria-label={`Select ${item.finding_title || item.finding_id}`}
- />
- )}
+
+ toggleItemSelection(item.id) : undefined}
+ role={selectionMode ? 'button' : undefined}
+ tabIndex={selectionMode ? 0 : undefined}
+ onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
+ >
+ {/* Selection checkbox (Requirement 1.2) */}
+ {selectionMode && (
+
{ e.stopPropagation(); toggleItemSelection(item.id); }}
+ onClick={(e) => e.stopPropagation()}
+ style={STYLES.checkbox}
+ aria-label={`Select ${item.finding_title || item.finding_id}`}
+ />
+ )}
- {/* Finding info */}
-
-
- {item.finding_title || item.finding_id}
-
- {cveDisplay && (
+ {/* Finding info */}
+
- {cveDisplay}
+ }} title={item.finding_title || item.finding_id}>
+ {item.finding_title || item.finding_id}
- )}
-
+ {cveDisplay && (
+
+ {cveDisplay}
+
+ )}
+
- {/* Ticket link badge (Requirements 6.3, 6.4) */}
- {ticketLinks[item.id] && (
-
e.stopPropagation()}
- style={{
+ {/* Archer Template toggle button (Requirement 5.1) */}
+ {isArcherItem && (
+
+ )}
+
+ {/* Ticket link badge (Requirements 6.3, 6.4) */}
+ {ticketLinks[item.id] && (
+ 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} ↗
+
+ )}
+
+ {/* Workflow type badge */}
+
+
- {ticketLinks[item.id].ticket_key} ↗
-
- )}
-
- {/* Workflow type badge */}
-
-
- {item.workflow_type}
-
-
-
- {/* Vendor */}
-
- {item.vendor || '—'}
-
-
- {/* Hostname / IP */}
-
- {item.hostname && (
-
- {item.hostname}
-
- )}
- {item.ip_address && (
-
- {item.ip_address}
-
- )}
+ {item.workflow_type}
+
+
+
+ {/* Vendor */}
+
+ {item.vendor || '—'}
+
+
+ {/* Hostname / IP */}
+
+ {item.hostname && (
+
+ {item.hostname}
+
+ )}
+ {item.ip_address && (
+
+ {item.ip_address}
+
+ )}
+
-
+
+ {/* Archer Template Selector expandable panel (Requirement 5.1) */}
+ {isArcherItem && isTemplatePanelOpen && (
+
+
+
+ )}
+
);
})}