From 2656df94d3974d871caeeb3422875cbf10b5b7cc Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Tue, 5 May 2026 11:04:53 -0600 Subject: [PATCH] feat: add multi-BU tenancy with per-user team scoping (Option B) - Add bu_teams column to users table (migration + fresh schema) - Create shared KNOWN_TEAMS constant and validateTeams helper - Expose user teams in auth middleware, login, and /me responses - Add bu_teams CRUD to user management routes with audit logging - Make Ivanti FINDINGS_FILTERS configurable via IVANTI_BU_FILTER env var - Add query-time team filtering to GET /findings and /findings/counts - Update AuthContext with teams helpers and admin scope toggle - Create AdminScopeToggle component (My Teams / All BUs) - Scope ReportingPage findings fetch by user teams - Scope CompliancePage team selector by user teams - Scope ExportsPage findings exports by user teams - Add BU teams multi-select to UserManagement create/edit forms - Display team badges in user list table --- backend/.env.example | 11 ++ ...jira-jql-window-invariant.property.test.js | 108 +++++++++++++ backend/__tests__/jira-route-removal.test.js | 146 ++++++++++++++++++ backend/helpers/jiraApi.js | 2 +- backend/helpers/teams.js | 26 ++++ backend/middleware/auth.js | 5 +- backend/migrations/add_user_bu_teams.js | 68 ++++++++ backend/routes/atlas.js | 87 ++++++++++- backend/routes/auth.js | 8 +- backend/routes/feedback.js | 121 +++++++++++++++ backend/routes/ivantiFindings.js | 52 ++++++- backend/routes/jiraTickets.js | 63 -------- backend/routes/users.js | 75 +++++++-- backend/server.js | 4 + backend/setup.js | 1 + docs/api/jira-api-use-cases.md | 13 +- frontend/src/components/AdminScopeToggle.js | 61 ++++++++ frontend/src/components/AtlasSlideOutPanel.js | 30 ++++ frontend/src/components/NavDrawer.js | 7 + frontend/src/components/UserManagement.js | 74 ++++++++- .../src/components/pages/CompliancePage.js | 28 +++- frontend/src/components/pages/ExportsPage.js | 39 +++-- .../src/components/pages/ReportingPage.js | 39 ++++- frontend/src/contexts/AuthContext.js | 58 ++++++- 24 files changed, 999 insertions(+), 127 deletions(-) create mode 100644 backend/__tests__/jira-jql-window-invariant.property.test.js create mode 100644 backend/__tests__/jira-route-removal.test.js create mode 100644 backend/helpers/teams.js create mode 100644 backend/migrations/add_user_bu_teams.js create mode 100644 backend/routes/feedback.js create mode 100644 frontend/src/components/AdminScopeToggle.js diff --git a/backend/.env.example b/backend/.env.example index d13d620..5191710 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,11 @@ IVANTI_API_KEY= IVANTI_CLIENT_ID=1550 IVANTI_FIRST_NAME= IVANTI_LAST_NAME= +# Comma-separated list of BU values to sync from Ivanti. +# Broadening this pulls findings for additional BUs into the local cache. +# Users see only their assigned teams' findings (filtered at query time). +# Default if unset: NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM +IVANTI_BU_FILTER=NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM # Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False) IVANTI_SKIP_TLS=false @@ -54,3 +59,9 @@ CARD_API_USER= CARD_API_PASS= # Set to true if behind Charter's SSL inspection proxy CARD_SKIP_TLS=false + +# GitLab Feedback Integration (bug reports and feature requests from the dashboard) +# PAT needs 'api' scope. Project ID is the numeric ID from GitLab project settings. +GITLAB_URL=http://steam-gitlab.charterlab.com +GITLAB_PROJECT_ID= +GITLAB_PAT= diff --git a/backend/__tests__/jira-jql-window-invariant.property.test.js b/backend/__tests__/jira-jql-window-invariant.property.test.js new file mode 100644 index 0000000..5e895e3 --- /dev/null +++ b/backend/__tests__/jira-jql-window-invariant.property.test.js @@ -0,0 +1,108 @@ +/** + * Property-Based Test: JQL Window Invariant + * + * Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72 hours in bulk sync + * + * For any non-empty array of valid-looking issue keys passed to searchIssuesByKeys(), + * the generated JQL string SHALL contain the substring `updated >= -72h` and + * SHALL contain the substring `project =`. + * + * Validates: Requirements 2.1, 2.3 + */ + +const fc = require('fast-check'); + +// Capture the JQL that flows through the HTTP layer. +let capturedJql = null; + +// Mock https to intercept the request URL (which contains the JQL) and return +// a fake 200 response. This prevents real network calls while letting the +// real searchIssuesByKeys → searchIssues → jiraGet → jiraRequest chain execute. +jest.mock('https', () => ({ + request: jest.fn((options, callback) => { + const fullPath = options.path || ''; + const jqlMatch = fullPath.match(/[?&]jql=([^&]*)/); + if (jqlMatch) { + capturedJql = decodeURIComponent(jqlMatch[1]); + } + + const mockResponse = { + statusCode: 200, + on: jest.fn((event, handler) => { + if (event === 'data') { + handler(JSON.stringify({ total: 0, issues: [] })); + } + if (event === 'end') { + handler(); + } + }), + }; + // Use setImmediate so the callback fires on the same tick after promises + // resolve, but still asynchronously as Node's http expects. + setImmediate(() => callback(mockResponse)); + + return { + on: jest.fn(), + write: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }; + }), +})); + +// Set required env vars before requiring the module so the module-level +// constants pick them up. +process.env.JIRA_PROJECT_KEY = 'TESTPROJ'; +process.env.JIRA_BASE_URL = 'https://jira.example.com'; +process.env.JIRA_API_USER = 'testuser'; +process.env.JIRA_API_TOKEN = 'testtoken'; + +const jiraApi = require('../helpers/jiraApi'); + +describe('Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72h in bulk sync', () => { + // Use fake timers so the rate-limiter's inter-request delays (1–2 seconds) + // resolve instantly. We preserve setImmediate so the https mock callback + // still fires asynchronously as expected. + beforeAll(() => { + jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] }); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + capturedJql = null; + }); + + // Generator: produces a valid Jira issue key like "AB-1", "PROJ-42", etc. + const issueKeyArb = fc.tuple( + fc.stringMatching(/^[A-Z]{2,10}$/), + fc.integer({ min: 1, max: 99999 }) + ).map(([prefix, num]) => `${prefix}-${num}`); + + // Generator: non-empty array of issue keys (1 to 50 keys) + const issueKeysArb = fc.array(issueKeyArb, { minLength: 1, maxLength: 50 }); + + it('searchIssuesByKeys() always generates JQL containing `updated >= -72h` and `project =`', async () => { + await fc.assert( + fc.asyncProperty(issueKeysArb, async (issueKeys) => { + capturedJql = null; + + // Start the call — it will hit waitForDelay which uses setTimeout + const promise = jiraApi.searchIssuesByKeys(issueKeys); + + // Advance fake timers to resolve any pending setTimeout from the + // rate limiter's waitForDelay function. + jest.advanceTimersByTime(5000); + + await promise; + + expect(capturedJql).not.toBeNull(); + expect(capturedJql).toContain('updated >= -72h'); + expect(capturedJql).toContain('project ='); + }), + { numRuns: 100 } + ); + }, 60000); +}); diff --git a/backend/__tests__/jira-route-removal.test.js b/backend/__tests__/jira-route-removal.test.js new file mode 100644 index 0000000..9114a89 --- /dev/null +++ b/backend/__tests__/jira-route-removal.test.js @@ -0,0 +1,146 @@ +/** + * Example-Based Tests: Route Removal and Remaining Routes + * + * Feature: jira-api-compliance-cleanup + * + * Property 2: Search route is absent from router (Example) + * After the route removal, a POST request to /api/jira/search SHALL return HTTP 404. + * Validates: Requirements 1.1, 1.2 + * + * Property 3: Existing routes remain functional after search route removal (Example) + * The routes GET /lookup/:issueKey, POST /sync-all, POST /:id/sync, and + * POST /create-in-jira SHALL continue to respond with non-404 status codes. + * Validates: Requirements 1.3, 1.4, 1.5, 1.6 + */ + +const http = require('http'); +const express = require('express'); + +// Mock the auth middleware so routes don't require real sessions/cookies. +jest.mock('../middleware/auth', () => ({ + requireAuth: () => (req, res, next) => { + req.user = { id: 1, username: 'test', group: 'Admin' }; + next(); + }, + requireGroup: () => (req, res, next) => next(), +})); + +// Mock the audit log helper to be a no-op. +jest.mock('../helpers/auditLog', () => jest.fn()); + +// Mock the jiraApi helper — mark it as not configured so routes return 503 +// (which is fine; we only care that they are NOT 404). +jest.mock('../helpers/jiraApi', () => ({ + isConfigured: false, + getRateLimitStatus: jest.fn(() => ({ + burst: { remaining: 60, limit: 60 }, + daily: { remaining: 1440, limit: 1440 }, + })), +})); + +const createJiraTicketsRouter = require('../routes/jiraTickets'); + +// Minimal db mock — callback-style methods that return empty results. +function createMockDb() { + return { + get: jest.fn((_sql, _params, cb) => cb(null, null)), + all: jest.fn((_sql, _params, cb) => cb(null, [])), + run: jest.fn(function (_sql, _params, cb) { + if (typeof cb === 'function') cb.call({ lastID: 1, changes: 0 }, null); + }), + }; +} + +/** + * Helper: send an HTTP request to the test server and return { statusCode }. + */ +function request(server, method, path, body) { + return new Promise((resolve, reject) => { + const addr = server.address(); + const options = { + hostname: '127.0.0.1', + port: addr.port, + path, + method, + headers: { 'Content-Type': 'application/json' }, + }; + + const req = http.request(options, (res) => { + // Consume the response body so the socket closes cleanly. + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + resolve({ statusCode: res.statusCode }); + }); + }); + + req.on('error', reject); + + if (body) { + req.write(JSON.stringify(body)); + } + req.end(); + }); +} + +describe('Feature: jira-api-compliance-cleanup — route removal tests', () => { + let app; + let server; + + beforeAll((done) => { + const db = createMockDb(); + app = express(); + app.use(express.json()); + app.use('/api/jira-tickets', createJiraTicketsRouter(db)); + + // Listen on a random available port. + server = app.listen(0, '127.0.0.1', done); + }); + + afterAll((done) => { + server.close(done); + }); + + // --------------------------------------------------------------------------- + // Property 2: POST /api/jira-tickets/search returns 404 + // Validates: Requirements 1.1, 1.2 + // --------------------------------------------------------------------------- + describe('Property 2: Search route is absent', () => { + it('POST /api/jira-tickets/search returns HTTP 404', async () => { + const res = await request(server, 'POST', '/api/jira-tickets/search', { + jql: 'project = TEST', + }); + expect(res.statusCode).toBe(404); + }); + }); + + // --------------------------------------------------------------------------- + // Property 3: Remaining routes respond with non-404 status codes + // Validates: Requirements 1.3, 1.4, 1.5, 1.6 + // --------------------------------------------------------------------------- + describe('Property 3: Existing routes remain functional', () => { + it('GET /api/jira-tickets/lookup/:issueKey returns non-404', async () => { + const res = await request(server, 'GET', '/api/jira-tickets/lookup/TEST-1'); + expect(res.statusCode).not.toBe(404); + }); + + it('POST /api/jira-tickets/sync-all returns non-404', async () => { + const res = await request(server, 'POST', '/api/jira-tickets/sync-all'); + expect(res.statusCode).not.toBe(404); + }); + + it('POST /api/jira-tickets/:id/sync returns non-404', async () => { + const res = await request(server, 'POST', '/api/jira-tickets/1/sync'); + expect(res.statusCode).not.toBe(404); + }); + + it('POST /api/jira-tickets/create-in-jira returns non-404', async () => { + const res = await request(server, 'POST', '/api/jira-tickets/create-in-jira', { + cve_id: 'CVE-2024-12345', + vendor: 'TestVendor', + summary: 'Test summary', + }); + expect(res.statusCode).not.toBe(404); + }); + }); +}); diff --git a/backend/helpers/jiraApi.js b/backend/helpers/jiraApi.js index ce5e89c..69cc885 100644 --- a/backend/helpers/jiraApi.js +++ b/backend/helpers/jiraApi.js @@ -304,7 +304,7 @@ async function searchIssuesByKeys(issueKeys, opts) { // or similar, but key-based search is inherently scoped. We add updated // clause for compliance. const keyList = issueKeys.map(k => `"${k}"`).join(', '); - const jql = `key in (${keyList}) AND updated >= -24h AND project = ${JIRA_PROJECT_KEY}`; + const jql = `key in (${keyList}) AND updated >= -72h AND project = ${JIRA_PROJECT_KEY}`; const fields = (opts && opts.fields) || DEFAULT_FIELDS; const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000); diff --git a/backend/helpers/teams.js b/backend/helpers/teams.js new file mode 100644 index 0000000..306eed7 --- /dev/null +++ b/backend/helpers/teams.js @@ -0,0 +1,26 @@ +// Shared BU team constants and validation +// Used by user management routes, auth middleware, and frontend-facing endpoints. + +const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']; + +/** + * Parse and validate a comma-separated teams string. + * @param {string} teamsString - Comma-separated team identifiers (e.g. 'STEAM,ACCESS-ENG') + * @returns {{ valid: boolean, teams: string[], invalid: string[] }} + */ +function validateTeams(teamsString) { + if (!teamsString || typeof teamsString !== 'string' || teamsString.trim() === '') { + return { valid: true, teams: [], invalid: [] }; + } + + const teams = teamsString.split(',').map(t => t.trim()).filter(Boolean); + const invalid = teams.filter(t => !KNOWN_TEAMS.includes(t)); + + return { + valid: invalid.length === 0, + teams, + invalid + }; +} + +module.exports = { KNOWN_TEAMS, validateTeams }; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index d5b5e95..8b92cd4 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -12,7 +12,7 @@ function requireAuth(db) { try { const session = await new Promise((resolve, reject) => { db.get( - `SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.is_active + `SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.session_id = ? AND s.expires_at > datetime('now')`, @@ -38,7 +38,8 @@ function requireAuth(db) { username: session.username, email: session.email, role: session.role, - group: session.user_group + group: session.user_group, + teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : [] }; next(); diff --git a/backend/migrations/add_user_bu_teams.js b/backend/migrations/add_user_bu_teams.js new file mode 100644 index 0000000..e3e1f9f --- /dev/null +++ b/backend/migrations/add_user_bu_teams.js @@ -0,0 +1,68 @@ +// Migration: Add bu_teams column to users table +// Stores comma-separated BU team identifiers per user (e.g. 'STEAM,ACCESS-ENG') +// Existing users get empty string (admin must assign teams post-migration) +// Idempotent — safe to run multiple times +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const DB_FILE = path.join(__dirname, '..', 'cve_database.db'); + +/** + * Run the migration against the given database instance. + * Exported for testing with in-memory databases. + * @param {sqlite3.Database} db + * @returns {Promise} + */ +function runMigration(db) { + return new Promise((resolve, reject) => { + db.serialize(() => { + // Check if bu_teams column already exists + db.all("PRAGMA table_info(users)", (err, columns) => { + if (err) { + reject(err); + return; + } + + const hasBuTeams = columns.some(col => col.name === 'bu_teams'); + + if (hasBuTeams) { + console.log('✓ bu_teams column already exists — skipping migration'); + resolve(); + return; + } + + console.log('Adding bu_teams column to users table...'); + + db.run( + `ALTER TABLE users ADD COLUMN bu_teams TEXT NOT NULL DEFAULT ''`, + (err) => { + if (err) { + reject(err); + return; + } + console.log('✓ Added bu_teams column (default: empty string)'); + console.log(' Note: Admin must assign teams to existing users via user management UI'); + resolve(); + } + ); + }); + }); + }); +} + +// Run directly if executed as a script +if (require.main === module) { + const db = new sqlite3.Database(DB_FILE); + runMigration(db) + .then(() => { + console.log('Migration complete.'); + db.close(); + }) + .catch((err) => { + console.error('Migration failed:', err.message); + db.close(); + process.exit(1); + }); +} + +module.exports = { runMigration }; diff --git a/backend/routes/atlas.js b/backend/routes/atlas.js index db48b28..d00cf51 100644 --- a/backend/routes/atlas.js +++ b/backend/routes/atlas.js @@ -7,9 +7,19 @@ const { requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi'); +const fs = require('fs'); +const path = require('path'); + const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion']; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +// Diagnostic log helper — writes to atlas-sync-debug.log in the backend folder +function syncLog(msg) { + const line = `${new Date().toISOString()} ${msg}\n`; + try { fs.appendFileSync(path.join(__dirname, '..', 'atlas-sync-debug.log'), line); } catch (_) { /* ignore */ } + console.log(msg); +} + // --------------------------------------------------------------------------- // DB helpers — promise wrappers for callback-based SQLite API // --------------------------------------------------------------------------- @@ -129,7 +139,7 @@ function createAtlasRouter(db, requireAuth) { try { const rows = await dbAll(db, - `SELECT host_id, has_action_plan, plan_count, synced_at FROM atlas_action_plans_cache` + `SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache` ); res.json(rows); } catch (err) { @@ -227,7 +237,34 @@ function createAtlasRouter(db, requireAuth) { const planCount = activePlans.length; const hasActionPlan = planCount > 0 ? 1 : 0; + console.log(`[Atlas Sync] Host ${hostId}: status=${result.status}, activePlans=${activePlans.length}, allPlans=${allPlans.length}, hasActionPlan=${hasActionPlan}`); + try { + // If Atlas returns 0 plans but we have a recent optimistic + // entry (from bulk creation within the last 10 minutes), + // keep the optimistic value — Atlas's GET may lag behind. + if (hasActionPlan === 0) { + const existing = await dbGet(db, + `SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = ?`, + [hostId] + ); + if (existing && existing.has_action_plan === 1) { + let existingPlans = []; + try { existingPlans = JSON.parse(existing.plans_json || '[]'); } catch (_) {} + const hasBulkStub = existingPlans.some(p => p.source === 'bulk-create'); + if (hasBulkStub) { + const ageMs = Date.now() - new Date(existing.synced_at + 'Z').getTime(); + const TEN_MINUTES = 10 * 60 * 1000; + if (ageMs < TEN_MINUTES) { + console.log(`[Atlas Sync] Host ${hostId}: keeping optimistic bulk-create entry (${Math.round(ageMs / 1000)}s old)`); + synced++; + withPlans++; + continue; + } + } + } + } + await dbRun(db, `INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at) VALUES (?, ?, ?, ?, datetime('now')) @@ -246,7 +283,7 @@ function createAtlasRouter(db, requireAuth) { if (hasActionPlan) withPlans++; } else { failed++; - console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}`); + console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}, body=${result.body}`); } } } @@ -506,6 +543,52 @@ function createAtlasRouter(db, requireAuth) { } catch (e) { body = result.body; } + + // Optimistically update local cache for all submitted hosts. + // Atlas's individual GET endpoint may lag behind the bulk + // creation, so we mark every host as having a plan now rather + // than waiting for the next sync to discover it. + for (const hid of host_ids) { + try { + const existing = await dbGet(db, + `SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = ?`, + [hid] + ); + + let existingPlans = []; + if (existing && existing.plans_json) { + try { existingPlans = JSON.parse(existing.plans_json); } catch (_) { /* ignore */ } + } + + const stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() }; + const updatedPlans = [...existingPlans, stubPlan]; + const newCount = updatedPlans.length; + + await dbRun(db, + `INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at) + VALUES (?, 1, ?, ?, datetime('now')) + ON CONFLICT(host_id) DO UPDATE SET + has_action_plan = 1, + plan_count = excluded.plan_count, + plans_json = excluded.plans_json, + synced_at = excluded.synced_at`, + [hid, newCount, JSON.stringify(updatedPlans)] + ); + } catch (cacheErr) { + console.error('[Atlas] Cache update failed for host', hid, ':', cacheErr.message); + } + } + + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'ATLAS_BULK_CREATE_PLANS', + entityType: 'atlas_action_plan', + entityId: null, + details: { host_ids, plan_type, commit_date, count: host_ids.length }, + ipAddress: req.ip + }); + res.status(result.status).json(body); } else { let errorBody; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index fb3ccd5..64704f7 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -143,7 +143,8 @@ function createAuthRouter(db, logAudit) { id: user.id, username: user.username, email: user.email, - group: user.user_group + group: user.user_group, + teams: user.bu_teams ? user.bu_teams.split(',').filter(Boolean) : [] } }); } catch (err) { @@ -222,7 +223,7 @@ function createAuthRouter(db, logAudit) { try { const session = await new Promise((resolve, reject) => { db.get( - `SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.is_active + `SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.session_id = ? AND s.expires_at > datetime('now')`, @@ -249,7 +250,8 @@ function createAuthRouter(db, logAudit) { id: session.user_id, username: session.username, email: session.email, - group: session.user_group + group: session.user_group, + teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : [] } }); } catch (err) { diff --git a/backend/routes/feedback.js b/backend/routes/feedback.js new file mode 100644 index 0000000..ffa9dc4 --- /dev/null +++ b/backend/routes/feedback.js @@ -0,0 +1,121 @@ +// Feedback route — proxies bug reports and feature requests to GitLab Issues API +// Keeps the GitLab PAT server-side so it's never exposed to the browser. + +const express = require('express'); +const https = require('https'); +const http = require('http'); + +function createFeedbackRouter(db, requireAuth) { + const router = express.Router(); + + const GITLAB_URL = process.env.GITLAB_URL || ''; + const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || ''; + const GITLAB_PAT = process.env.GITLAB_PAT || ''; + + /** + * POST /api/feedback + * + * Create a GitLab issue from a bug report or feature request. + * Available to all authenticated users. + * + * @body {string} type - "bug" or "feature" + * @body {string} title - Issue title + * @body {string} description - Issue description + * @body {string} [page] - Which dashboard page the user was on + */ + router.post('/', requireAuth, async (req, res) => { + if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) { + return res.status(503).json({ error: 'Feedback integration not configured' }); + } + + const { type, title, description, page } = req.body; + + if (!type || !title || !description) { + return res.status(400).json({ error: 'type, title, and description are required' }); + } + + if (!['bug', 'feature'].includes(type)) { + return res.status(400).json({ error: 'type must be "bug" or "feature"' }); + } + + const labels = type === 'bug' ? 'bug' : 'enhancement'; + const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request'; + const username = req.user?.username || 'unknown'; + + const body = [ + `**Submitted by:** ${username}`, + page ? `**Page:** ${page}` : null, + `**Type:** ${prefix}`, + '', + '---', + '', + description, + ].filter(Boolean).join('\n'); + + const postData = JSON.stringify({ + title: `[${prefix}] ${title}`, + description: body, + labels, + }); + + const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`; + + try { + const result = await new Promise((resolve, reject) => { + const parsed = new URL(apiUrl); + const transport = parsed.protocol === 'https:' ? https : http; + + const reqOpts = { + method: 'POST', + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname + parsed.search, + headers: { + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': GITLAB_PAT, + 'Content-Length': Buffer.byteLength(postData), + }, + rejectAuthorized: false, + }; + + const apiReq = transport.request(reqOpts, (apiRes) => { + let data = ''; + apiRes.on('data', chunk => data += chunk); + apiRes.on('end', () => { + try { + resolve({ status: apiRes.statusCode, body: JSON.parse(data) }); + } catch { + resolve({ status: apiRes.statusCode, body: data }); + } + }); + }); + + apiReq.on('error', reject); + apiReq.write(postData); + apiReq.end(); + }); + + if (result.status === 201) { + console.log(`[Feedback] Issue #${result.body.iid} created by ${username}: ${title}`); + res.json({ + success: true, + issue: { + id: result.body.iid, + url: result.body.web_url, + title: result.body.title, + }, + }); + } else { + console.error(`[Feedback] GitLab API returned ${result.status}:`, result.body); + res.status(502).json({ error: 'GitLab API error', details: result.body }); + } + } catch (err) { + console.error('[Feedback] Request failed:', err.message); + res.status(502).json({ error: 'Failed to connect to GitLab' }); + } + }); + + return router; +} + +module.exports = createFeedbackRouter; diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 10b35b9..ab9a704 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -8,6 +8,10 @@ const { ivantiPost } = require('../helpers/ivantiApi'); const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; +// Configurable BU filter — broadened via env var to support multi-tenancy. +// Users see only their assigned teams' findings (filtered at query time). +const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM'; + const FINDINGS_FILTERS = [ // NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount() { @@ -16,7 +20,7 @@ const FINDINGS_FILTERS = [ operator: 'IN', orWithPrevious: false, implicitFilters: [], - value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM', + value: BU_FILTER_VALUE, caseSensitive: false }, { @@ -47,7 +51,7 @@ const CLOSED_COUNT_FILTERS = [ operator: 'IN', orWithPrevious: false, implicitFilters: [], - value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM', + value: BU_FILTER_VALUE, caseSensitive: false }, { @@ -1118,13 +1122,30 @@ function createIvantiFindingsRouter(db, requireAuth) { * GET /api/ivanti/findings * * Return cached Ivanti findings with notes and overrides merged in. + * Accepts optional `teams` query parameter (comma-separated) to filter + * findings by buOwnership. If omitted, returns all findings. * - * @returns {Object} 200 - { findings: Array, lastSync: string|null, overrides: Object } + * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') + * @returns {Object} 200 - { findings: Array, lastSync: string|null, overrides: Object, total: number } * @returns {Object} 500 - { error: string } on database error */ router.get('/', async (req, res) => { try { - res.json(await readStateWithNotes(db)); + const state = await readStateWithNotes(db); + + // Filter by teams if provided + const teamsParam = req.query.teams; + if (teamsParam) { + const teams = teamsParam.split(',').map(t => t.trim().toUpperCase()).filter(Boolean); + if (teams.length > 0) { + state.findings = state.findings.filter(f => + teams.some(t => (f.buOwnership || '').toUpperCase().includes(t)) + ); + state.total = state.findings.length; + } + } + + res.json(state); } catch { res.status(500).json({ error: 'Database error reading findings' }); } @@ -1152,13 +1173,32 @@ function createIvantiFindingsRouter(db, requireAuth) { * GET /api/ivanti/findings/counts * * Return open vs closed finding totals for the pie chart. + * Accepts optional `teams` query parameter to scope the open count + * to specific BUs. Closed count remains global (approximate) when filtered. * - * @returns {Object} 200 - { open: number, closed: number } + * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') + * @returns {Object} 200 - { open: number, closed: number, filtered: boolean } * @returns {Object} 500 - { error: string } on database error */ router.get('/counts', async (req, res) => { try { - res.json(await readCounts(db)); + const teamsParam = req.query.teams; + + if (teamsParam) { + const teams = teamsParam.split(',').map(t => t.trim().toUpperCase()).filter(Boolean); + if (teams.length > 0) { + // For open count, filter the cached findings by team + const state = await readState(db); + const filtered = state.findings.filter(f => + teams.some(t => (f.buOwnership || '').toUpperCase().includes(t)) + ); + // Closed count is global — we don't store per-finding closed data + const counts = await readCounts(db); + return res.json({ open: filtered.length, closed: counts.closed, filtered: true }); + } + } + + res.json({ ...(await readCounts(db)), filtered: false }); } catch { res.status(500).json({ error: 'Database error reading counts' }); } diff --git a/backend/routes/jiraTickets.js b/backend/routes/jiraTickets.js index cf29375..996237b 100644 --- a/backend/routes/jiraTickets.js +++ b/backend/routes/jiraTickets.js @@ -132,69 +132,6 @@ function createJiraTicketsRouter(db) { } }); - /** - * POST /api/jira/search - * - * Search Jira issues using a JQL query. Results are capped at 1000 per page. - * Charter compliance: JQL must include project+updated, assignee+updated, - * or status+updated. Fields are always specified explicitly. - * - * @body {string} jql - JQL query string (required, max 2000 chars) - * @body {number} [startAt] - Pagination offset - * @body {number} [maxResults] - Page size (max 1000) - * @body {string[]} [fields] - Explicit field list for the Jira response - * @returns {object} 200 - { total, startAt, maxResults, issues: [{ key, summary, status, assignee, priority, issuetype, created, updated }] } - * @returns {object} 400 - { error } when JQL is missing or too long - * @returns {object} 429 - { error } when Jira rate limit exceeded - * @returns {object} 502 - { error, details } on Jira search failure - * @returns {object} 503 - { error } when Jira API is not configured - */ - router.post('/search', requireAuth(db), async (req, res) => { - if (!jiraApi.isConfigured) { - return res.status(503).json({ error: 'Jira API is not configured.' }); - } - - const { jql, startAt, maxResults, fields } = req.body; - if (!jql || typeof jql !== 'string' || jql.trim().length === 0) { - return res.status(400).json({ error: 'JQL query is required.' }); - } - if (jql.length > 2000) { - return res.status(400).json({ error: 'JQL query too long (max 2000 chars).' }); - } - - try { - const result = await jiraApi.searchIssues(jql, { - startAt, - maxResults: Math.min(maxResults || 1000, 1000), - fields: fields || undefined - }); - if (result.ok) { - const data = result.data; - return res.json({ - total: data.total, - startAt: data.startAt, - maxResults: data.maxResults, - issues: (data.issues || []).map(issue => ({ - key: issue.key, - summary: issue.fields.summary, - status: issue.fields.status ? issue.fields.status.name : null, - assignee: issue.fields.assignee ? issue.fields.assignee.displayName : null, - priority: issue.fields.priority ? issue.fields.priority.name : null, - issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null, - created: issue.fields.created, - updated: issue.fields.updated - })) - }); - } - if (result.rateLimited) { - return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' }); - } - return res.status(502).json({ error: 'Jira search failed.', details: result.body }); - } catch (err) { - return res.status(502).json({ error: err.message }); - } - }); - /** * POST /api/jira/create-in-jira * diff --git a/backend/routes/users.js b/backend/routes/users.js index 6b663da..e7cb9f4 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,6 +1,7 @@ // User Management Routes (Admin only) const express = require('express'); const bcrypt = require('bcryptjs'); +const { validateTeams } = require('../helpers/teams'); function createUsersRouter(db, requireAuth, requireGroup, logAudit) { const router = express.Router(); @@ -13,7 +14,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { try { const users = await new Promise((resolve, reject) => { db.all( - `SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login + `SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login FROM users ORDER BY created_at DESC`, (err, rows) => { if (err) reject(err); @@ -21,7 +22,12 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { } ); }); - res.json(users); + // Parse bu_teams into teams array for each user + const usersWithTeams = users.map(u => ({ + ...u, + teams: u.bu_teams ? u.bu_teams.split(',').filter(Boolean) : [] + })); + res.json(usersWithTeams); } catch (err) { console.error('Get users error:', err); res.status(500).json({ error: 'Failed to fetch users' }); @@ -33,7 +39,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { try { const user = await new Promise((resolve, reject) => { db.get( - `SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login + `SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login FROM users WHERE id = ?`, [req.params.id], (err, row) => { @@ -47,7 +53,10 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { return res.status(404).json({ error: 'User not found' }); } - res.json(user); + res.json({ + ...user, + teams: user.bu_teams ? user.bu_teams.split(',').filter(Boolean) : [] + }); } catch (err) { console.error('Get user error:', err); res.status(500).json({ error: 'Failed to fetch user' }); @@ -56,7 +65,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { // Create new user router.post('/', async (req, res) => { - const { username, email, password, group } = req.body; + const { username, email, password, group, bu_teams } = req.body; const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only']; if (!username || !email || !password) { @@ -69,14 +78,23 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' }); } + // Validate bu_teams if provided + const teamsStr = bu_teams || ''; + if (teamsStr) { + const teamsResult = validateTeams(teamsStr); + if (!teamsResult.valid) { + return res.status(400).json({ error: `Invalid team(s): ${teamsResult.invalid.join(', ')}. Must be one of: STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV` }); + } + } + try { const passwordHash = await bcrypt.hash(password, 10); const result = await new Promise((resolve, reject) => { db.run( - `INSERT INTO users (username, email, password_hash, user_group) - VALUES (?, ?, ?, ?)`, - [username, email, passwordHash, userGroup], + `INSERT INTO users (username, email, password_hash, user_group, bu_teams) + VALUES (?, ?, ?, ?, ?)`, + [username, email, passwordHash, userGroup, teamsStr], function(err) { if (err) reject(err); else resolve({ id: this.lastID }); @@ -90,7 +108,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { action: 'user_create', entityType: 'user', entityId: String(result.id), - details: { created_username: username, group: userGroup }, + details: { created_username: username, group: userGroup, bu_teams: teamsStr }, ipAddress: req.ip }); @@ -100,7 +118,9 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { id: result.id, username, email, - group: userGroup + group: userGroup, + bu_teams: teamsStr, + teams: teamsStr ? teamsStr.split(',').filter(Boolean) : [] } }); } catch (err) { @@ -114,7 +134,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { // Update user router.patch('/:id', async (req, res) => { - const { username, email, password, group, is_active } = req.body; + const { username, email, password, group, is_active, bu_teams } = req.body; const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only']; const userId = req.params.id; @@ -133,11 +153,21 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { return res.status(400).json({ error: 'Cannot deactivate your own account' }); } + // Validate bu_teams if provided + if (typeof bu_teams === 'string') { + if (bu_teams !== '') { + const teamsResult = validateTeams(bu_teams); + if (!teamsResult.valid) { + return res.status(400).json({ error: `Invalid team(s): ${teamsResult.invalid.join(', ')}. Must be one of: STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV` }); + } + } + } + try { // Fetch current user record before update (needed for group change audit) const currentUser = await new Promise((resolve, reject) => { db.get( - 'SELECT user_group FROM users WHERE id = ?', + 'SELECT user_group, bu_teams FROM users WHERE id = ?', [userId], (err, row) => { if (err) reject(err); @@ -174,6 +204,10 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { updates.push('is_active = ?'); values.push(is_active ? 1 : 0); } + if (typeof bu_teams === 'string') { + updates.push('bu_teams = ?'); + values.push(bu_teams); + } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); @@ -198,6 +232,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { if (group) updatedFields.group = group; if (typeof is_active === 'boolean') updatedFields.is_active = is_active; if (password) updatedFields.password_changed = true; + if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams; logAudit(db, { userId: req.user.id, @@ -225,6 +260,22 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { }); } + // Log specific audit entry for bu_teams changes + if (typeof bu_teams === 'string' && bu_teams !== (currentUser.bu_teams || '')) { + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'user_teams_change', + entityType: 'user', + entityId: String(userId), + details: { + previous_teams: currentUser.bu_teams || '', + new_teams: bu_teams + }, + ipAddress: req.ip + }); + } + // If user was deactivated, delete their sessions if (is_active === false) { await new Promise((resolve) => { diff --git a/backend/server.js b/backend/server.js index 587475d..1362420 100644 --- a/backend/server.js +++ b/backend/server.js @@ -29,6 +29,7 @@ const { createComplianceRouter } = require('./routes/compliance'); const createAtlasRouter = require('./routes/atlas'); const createJiraTicketsRouter = require('./routes/jiraTickets'); const createCardApiRouter = require('./routes/cardApi'); +const createFeedbackRouter = require('./routes/feedback'); const app = express(); const PORT = process.env.PORT || 3001; @@ -246,6 +247,9 @@ app.use('/api/jira-tickets', createJiraTicketsRouter(db)); // CARD Asset Ownership API routes — proxy CARD operations, mutation flow, asset search app.use('/api/card', createCardApiRouter(db, requireAuth)); +// Feedback routes — bug reports and feature requests to GitLab +app.use('/api/feedback', createFeedbackRouter(db, requireAuth)); + // ========== CVE ENDPOINTS ========== // Get all CVEs with optional filters (authenticated users) diff --git a/backend/setup.js b/backend/setup.js index c6688fe..29e7f52 100755 --- a/backend/setup.js +++ b/backend/setup.js @@ -114,6 +114,7 @@ async function initializeDatabase(db) { created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_login TIMESTAMP, user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only', + bu_teams TEXT NOT NULL DEFAULT '', CHECK (role IN ('admin', 'editor', 'viewer')) ); diff --git a/docs/api/jira-api-use-cases.md b/docs/api/jira-api-use-cases.md index ce5dab8..12961cd 100644 --- a/docs/api/jira-api-use-cases.md +++ b/docs/api/jira-api-use-cases.md @@ -19,9 +19,9 @@ All API calls are made from a single Node.js backend process. The integration us | Inter-request delay — writes | 2 seconds minimum between PUT/POST/DELETE requests | | Explicit field lists | Every GET includes `?fields=` parameter; `/rest/api/2/field` is blocked | | No bulk updates | Issues are updated one at a time; `/rest/api/2/issue/bulk` is blocked | -| Bulk reads via JQL | Multi-ticket sync uses a single `GET /rest/api/2/search` with JQL query parameters, not per-issue GETs | +| Bulk reads via JQL | Multi-ticket sync uses a single `GET /rest/api/2/search` with predefined key-based JQL query parameters, not per-issue GETs; no arbitrary JQL passthrough | | Single-issue fetch via JQL | `GET /rest/api/2/search?jql=key="KEY" AND project=&fields=...&maxResults=1` | -| JQL scoping | All recurring JQL queries include `updated >= -Xh` clause and `project = ` scoping | +| JQL scoping | All recurring JQL queries use predefined scoped patterns with `updated >= -72h` clause and `project = ` scoping; no arbitrary JQL passthrough | | `maxResults` cap | Search queries capped at 1 000 results per page | --- @@ -96,7 +96,7 @@ All API calls are made from a single Node.js backend process. The integration us | **Frequency** | Manual, estimated 5–10 per day | | **Purpose** | Move ticket through workflow states (e.g., Open to In Progress to Closed) | -### 8. JQL Search (Bulk Sync) +### 8. Scoped Bulk Sync via JQL | | | |---|---| @@ -104,10 +104,10 @@ All API calls are made from a single Node.js backend process. The integration us | **Trigger** | Admin clicks "Sync All" on the Jira tickets panel | | **Frequency** | Manual, estimated 1–3 times per day | | **Purpose** | Bulk-refresh all tracked tickets in a single request instead of per-issue GETs | -| **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -24h AND project = ` | +| **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -72h AND project = ` | | **Fields requested** | `summary, status, assignee, created, updated, priority, issuetype, project, resolution` | | **Batch size** | 100 keys per JQL query; multiple batches if needed | -| **Notes** | Uses GET with URL-encoded query parameters per Charter compliance. Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) | +| **Notes** | Uses GET with URL-encoded query parameters per Charter compliance. JQL is predefined and scoped — constructed from known tracked issue keys, a fixed 72-hour window, and the configured project key. No arbitrary JQL is accepted from the frontend. Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) | ### 9. Issue Lookup @@ -131,7 +131,7 @@ All API calls are made from a single Node.js backend process. The integration us | Add comment | 5–15 | POST | 2s | | Get transitions | 5–10 | GET | 1s | | Transition issue | 5–10 | POST | 2s | -| JQL search (sync) | 1–5 | GET | 1s | +| Scoped bulk sync | 1–5 | GET | 1s | | Issue lookup | 5–15 | GET | 1s | | **Total estimated** | **43–120** | | | @@ -145,6 +145,7 @@ The integration explicitly blocks these endpoints to comply with Charter policy: - `/rest/api/2/field` — field metadata is never queried; fields are specified in code - `/rest/api/2/issue/bulk` — bulk updates are not used; issues are updated individually +- `POST /rest/api/2/search` — arbitrary JQL search via POST is not used; all searches use `GET /rest/api/2/search` with URL-encoded query parameters and predefined scoped JQL patterns --- diff --git a/frontend/src/components/AdminScopeToggle.js b/frontend/src/components/AdminScopeToggle.js new file mode 100644 index 0000000..03feb8a --- /dev/null +++ b/frontend/src/components/AdminScopeToggle.js @@ -0,0 +1,61 @@ +// AdminScopeToggle.js +// Two-state toggle for Admin users: "My Teams" vs "All BUs" +// Controls whether data on Reporting, Compliance, and Exports pages +// is scoped to the admin's assigned teams or shows everything. + +import React from 'react'; +import { useAuth } from '../contexts/AuthContext'; + +function AdminScopeToggle() { + const { isAdmin, adminScope, toggleAdminScope, hasTeams } = useAuth(); + + // Only render for Admin users who have teams assigned + // (if no teams assigned, both modes are identical — no toggle needed) + if (!isAdmin() || !hasTeams()) return null; + + const isAllMode = adminScope === 'all'; + + return ( +
+ Scope: + +
+ ); +} + +export default AdminScopeToggle; diff --git a/frontend/src/components/AtlasSlideOutPanel.js b/frontend/src/components/AtlasSlideOutPanel.js index 1565ff6..07289d5 100644 --- a/frontend/src/components/AtlasSlideOutPanel.js +++ b/frontend/src/components/AtlasSlideOutPanel.js @@ -510,6 +510,36 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys } const data = await res.json(); const remotePlans = parseAtlasPlans(data); + + // If Atlas returns no plans, check local cache for optimistic bulk-create stubs + if (remotePlans.length === 0) { + try { + const cacheRes = await fetch(`${API_BASE}/atlas/status`, { credentials: 'include' }); + if (cacheRes.ok) { + const cacheData = await cacheRes.json(); + const hostCache = cacheData.find(r => r.host_id === hostId); + if (hostCache && hostCache.has_action_plan === 1 && hostCache.plans_json) { + let cachedPlans = []; + try { cachedPlans = typeof hostCache.plans_json === 'string' ? JSON.parse(hostCache.plans_json) : hostCache.plans_json; } catch (_) {} + const stubs = cachedPlans + .filter(p => p.source === 'bulk-create') + .map((p, i) => ({ + action_plan_id: 'pending-' + hostId + '-' + i, + plan_type: p.plan_type || 'unknown', + commit_date: p.commit_date || '', + status: 'pending', + _localPending: true, + created_at: p.created_at || '', + })); + if (stubs.length > 0) { + setPlans(stubs); + return; + } + } + } + } catch (_) { /* ignore cache fallback errors */ } + } + // Merge: keep local pending plans that aren't yet confirmed by Atlas setPlans(prev => { const localPending = prev.filter(p => p._localPending); diff --git a/frontend/src/components/NavDrawer.js b/frontend/src/components/NavDrawer.js index 7816304..808862c 100644 --- a/frontend/src/components/NavDrawer.js +++ b/frontend/src/components/NavDrawer.js @@ -1,6 +1,7 @@ import React from 'react'; import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; +import AdminScopeToggle from './AdminScopeToggle'; const NAV_ITEMS = [ { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' }, @@ -63,6 +64,12 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {/* Nav items */}