fix: address all 11 review items for group-based access control
Bugs fixed: - knowledgeBase.js: logAudit calls converted from positional args to object signature - archerTickets.js: targetType/targetId renamed to entityType/entityId - server.js: single CVE delete now has cascade/compliance check for Standard_User Unprotected endpoints secured: - ivantiTodoQueue.js: POST/PUT/DELETE now require Admin or Standard_User - ivantiFindings.js: PUT note and POST sync now require Admin or Standard_User - compliance.js: POST notes now requires Admin or Standard_User - ivantiWorkflows.js: POST sync now requires Admin or Standard_User - auth.js: cleanup-sessions now requires Admin via requireAuth + requireGroup Additional fixes: - ExportsPage.js: canExport() guard blocks Read_Only users - knowledgeBase.js: Standard_User delete checks created_by ownership - Migration: added INSERT/UPDATE triggers to enforce valid user_group values
This commit is contained in:
@@ -78,8 +78,35 @@ function runMigration(db) {
|
|||||||
(err) => {
|
(err) => {
|
||||||
if (err) { reject(err); return; }
|
if (err) { reject(err); return; }
|
||||||
console.log('✓ Created idx_users_user_group index');
|
console.log('✓ Created idx_users_user_group index');
|
||||||
console.log('Migration complete!');
|
|
||||||
resolve();
|
// Add CHECK constraint via trigger (SQLite can't ALTER TABLE ADD CONSTRAINT)
|
||||||
|
db.run(
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS check_user_group_insert
|
||||||
|
BEFORE INSERT ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
|
||||||
|
END`,
|
||||||
|
(err) => {
|
||||||
|
if (err) { reject(err); return; }
|
||||||
|
db.run(
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS check_user_group_update
|
||||||
|
BEFORE UPDATE OF user_group ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
|
||||||
|
END`,
|
||||||
|
(err) => {
|
||||||
|
if (err) { reject(err); return; }
|
||||||
|
console.log('✓ Created user_group validation triggers');
|
||||||
|
console.log('Migration complete!');
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ function createArcherTicketsRouter(db) {
|
|||||||
logAudit(db, {
|
logAudit(db, {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
action: 'CREATE_ARCHER_TICKET',
|
action: 'CREATE_ARCHER_TICKET',
|
||||||
targetType: 'archer_ticket',
|
entityType: 'archer_ticket',
|
||||||
targetId: this.lastID,
|
entityId: String(this.lastID),
|
||||||
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
|
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
@@ -172,8 +172,8 @@ function createArcherTicketsRouter(db) {
|
|||||||
logAudit(db, {
|
logAudit(db, {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
action: 'UPDATE_ARCHER_TICKET',
|
action: 'UPDATE_ARCHER_TICKET',
|
||||||
targetType: 'archer_ticket',
|
entityType: 'archer_ticket',
|
||||||
targetId: id,
|
entityId: String(id),
|
||||||
details: { before: existing, changes: req.body },
|
details: { before: existing, changes: req.body },
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
@@ -195,8 +195,8 @@ function createArcherTicketsRouter(db) {
|
|||||||
logAudit(db, {
|
logAudit(db, {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
action: 'DELETE_ARCHER_TICKET',
|
action: 'DELETE_ARCHER_TICKET',
|
||||||
targetType: 'archer_ticket',
|
entityType: 'archer_ticket',
|
||||||
targetId: id,
|
entityId: String(id),
|
||||||
details: { deleted: ticket },
|
details: { deleted: ticket },
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||||
|
|
||||||
function createAuthRouter(db, logAudit) {
|
function createAuthRouter(db, logAudit) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -220,12 +221,7 @@ function createAuthRouter(db, logAudit) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Clean up expired sessions (admin only)
|
// Clean up expired sessions (admin only)
|
||||||
router.post('/cleanup-sessions', async (req, res) => {
|
router.post('/cleanup-sessions', requireAuth(db), requireGroup('Admin'), async (req, res) => {
|
||||||
// Basic auth check - require a valid session to call this
|
|
||||||
const sessionId = req.cookies?.session_id;
|
|
||||||
if (!sessionId) {
|
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
|
|||||||
@@ -520,7 +520,7 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
|||||||
// Add a note to a (hostname, metric_id) pair.
|
// Add a note to a (hostname, metric_id) pair.
|
||||||
// Body: { hostname, metric_id, note }
|
// Body: { hostname, metric_id, note }
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
router.post('/notes', async (req, res) => {
|
router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const { hostname, metric_id, note } = req.body;
|
const { hostname, metric_id, note } = req.body;
|
||||||
|
|
||||||
if (!hostname || typeof hostname !== 'string' || hostname.length > 300) {
|
if (!hostname || typeof hostname !== 'string' || hostname.length > 300) {
|
||||||
|
|||||||
@@ -829,7 +829,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /sync — trigger immediate sync, return fresh state
|
// POST /sync — trigger immediate sync, return fresh state
|
||||||
router.post('/sync', async (req, res) => {
|
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
await syncFindings(db);
|
await syncFindings(db);
|
||||||
try {
|
try {
|
||||||
res.json(await readStateWithNotes(db));
|
res.json(await readStateWithNotes(db));
|
||||||
@@ -934,7 +934,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||||
router.put('/:findingId/note', (req, res) => {
|
router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { findingId } = req.params;
|
const { findingId } = req.params;
|
||||||
const note = String(req.body.note || '').slice(0, 255);
|
const note = String(req.body.note || '').slice(0, 255);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// routes/ivantiTodoQueue.js
|
// routes/ivantiTodoQueue.js
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { requireGroup } = require('../middleware/auth');
|
||||||
|
|
||||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
|
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
|
||||||
const VALID_STATUSES = ['pending', 'complete'];
|
const VALID_STATUSES = ['pending', 'complete'];
|
||||||
@@ -36,7 +37,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
|
|
||||||
// POST /api/ivanti/todo-queue
|
// POST /api/ivanti/todo-queue
|
||||||
// Add a finding to the queue
|
// Add a finding to the queue
|
||||||
router.post('/', requireAuth(db), (req, res) => {
|
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body;
|
const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body;
|
||||||
|
|
||||||
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
|
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
|
||||||
@@ -86,7 +87,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
|
|
||||||
// PUT /api/ivanti/todo-queue/:id
|
// PUT /api/ivanti/todo-queue/:id
|
||||||
// Update vendor, workflow_type, or status — scoped to current user
|
// Update vendor, workflow_type, or status — scoped to current user
|
||||||
router.put('/:id', requireAuth(db), (req, res) => {
|
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { vendor, workflow_type, status } = req.body;
|
const { vendor, workflow_type, status } = req.body;
|
||||||
|
|
||||||
@@ -162,7 +163,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
// DELETE /api/ivanti/todo-queue/completed
|
// DELETE /api/ivanti/todo-queue/completed
|
||||||
// Bulk-delete all completed items for the current user
|
// Bulk-delete all completed items for the current user
|
||||||
// IMPORTANT: This route must be registered BEFORE DELETE /:id
|
// IMPORTANT: This route must be registered BEFORE DELETE /:id
|
||||||
router.delete('/completed', requireAuth(db), (req, res) => {
|
router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
db.run(
|
db.run(
|
||||||
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
|
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
|
||||||
[req.user.id],
|
[req.user.id],
|
||||||
@@ -178,7 +179,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
|
|
||||||
// DELETE /api/ivanti/todo-queue/:id
|
// DELETE /api/ivanti/todo-queue/:id
|
||||||
// Delete a single item — scoped to current user
|
// Delete a single item — scoped to current user
|
||||||
router.delete('/:id', requireAuth(db), (req, res) => {
|
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
db.get(
|
db.get(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
const { requireGroup } = require('../middleware/auth');
|
||||||
|
|
||||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
@@ -259,7 +260,7 @@ function createIvantiWorkflowsRouter(db, requireAuth) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /sync — trigger an immediate sync, await completion, return fresh state
|
// POST /sync — trigger an immediate sync, await completion, return fresh state
|
||||||
router.post('/sync', async (req, res) => {
|
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
await syncWorkflows(db);
|
await syncWorkflows(db);
|
||||||
try {
|
try {
|
||||||
res.json(await readState(db));
|
res.json(await readState(db));
|
||||||
|
|||||||
@@ -132,16 +132,15 @@ function createKnowledgeBaseRouter(db, upload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log audit entry
|
// Log audit entry
|
||||||
logAudit(
|
logAudit(db, {
|
||||||
db,
|
userId: req.user.id,
|
||||||
req.user.id,
|
username: req.user.username,
|
||||||
req.user.username,
|
action: 'CREATE_KB_ARTICLE',
|
||||||
'CREATE_KB_ARTICLE',
|
entityType: 'knowledge_base',
|
||||||
'knowledge_base',
|
entityId: String(this.lastID),
|
||||||
this.lastID,
|
details: { title: title.trim(), filename: sanitizedName },
|
||||||
JSON.stringify({ title: title.trim(), filename: sanitizedName }),
|
ipAddress: req.ip
|
||||||
req.ip
|
});
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -232,16 +231,15 @@ function createKnowledgeBaseRouter(db, upload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log audit entry
|
// Log audit entry
|
||||||
logAudit(
|
logAudit(db, {
|
||||||
db,
|
userId: req.user.id,
|
||||||
req.user.id,
|
username: req.user.username,
|
||||||
req.user.username,
|
action: 'VIEW_KB_ARTICLE',
|
||||||
'VIEW_KB_ARTICLE',
|
entityType: 'knowledge_base',
|
||||||
'knowledge_base',
|
entityId: String(id),
|
||||||
id,
|
details: { filename: row.file_name },
|
||||||
JSON.stringify({ filename: row.file_name }),
|
ipAddress: req.ip
|
||||||
req.ip
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Determine content type for inline display
|
// Determine content type for inline display
|
||||||
let contentType = row.file_type || 'application/octet-stream';
|
let contentType = row.file_type || 'application/octet-stream';
|
||||||
@@ -284,16 +282,15 @@ function createKnowledgeBaseRouter(db, upload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log audit entry
|
// Log audit entry
|
||||||
logAudit(
|
logAudit(db, {
|
||||||
db,
|
userId: req.user.id,
|
||||||
req.user.id,
|
username: req.user.username,
|
||||||
req.user.username,
|
action: 'DOWNLOAD_KB_ARTICLE',
|
||||||
'DOWNLOAD_KB_ARTICLE',
|
entityType: 'knowledge_base',
|
||||||
'knowledge_base',
|
entityId: String(id),
|
||||||
id,
|
details: { filename: row.file_name },
|
||||||
JSON.stringify({ filename: row.file_name }),
|
ipAddress: req.ip
|
||||||
req.ip
|
});
|
||||||
);
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
|
||||||
@@ -305,7 +302,7 @@ function createKnowledgeBaseRouter(db, upload) {
|
|||||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
const sql = 'SELECT file_path, title FROM knowledge_base WHERE id = ?';
|
const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?';
|
||||||
|
|
||||||
db.get(sql, [id], (err, row) => {
|
db.get(sql, [id], (err, row) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -317,6 +314,11 @@ function createKnowledgeBaseRouter(db, upload) {
|
|||||||
return res.status(404).json({ error: 'Article not found' });
|
return res.status(404).json({ error: 'Article not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ownership check: Standard_User can only delete articles they created
|
||||||
|
if (req.user.group === 'Standard_User' && row.created_by !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||||
|
}
|
||||||
|
|
||||||
// Delete database record
|
// Delete database record
|
||||||
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
|
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -330,16 +332,15 @@ function createKnowledgeBaseRouter(db, upload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log audit entry
|
// Log audit entry
|
||||||
logAudit(
|
logAudit(db, {
|
||||||
db,
|
userId: req.user.id,
|
||||||
req.user.id,
|
username: req.user.username,
|
||||||
req.user.username,
|
action: 'DELETE_KB_ARTICLE',
|
||||||
'DELETE_KB_ARTICLE',
|
entityType: 'knowledge_base',
|
||||||
'knowledge_base',
|
entityId: String(id),
|
||||||
id,
|
details: { title: row.title },
|
||||||
JSON.stringify({ title: row.title }),
|
ipAddress: req.ip
|
||||||
req.ip
|
});
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -846,6 +846,59 @@ app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_Use
|
|||||||
return res.status(403).json({ error: 'You can only delete resources you created' });
|
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cascade/compliance check for Standard_User
|
||||||
|
if (req.user.group === 'Standard_User') {
|
||||||
|
return db.all('SELECT id, exc_number FROM archer_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (archerErr, archerTickets) => {
|
||||||
|
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
db.all('SELECT id, ticket_key FROM jira_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (jiraErr, jiraTickets) => {
|
||||||
|
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) { jiraTickets = []; }
|
||||||
|
else if (jiraErr) { console.error(jiraErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
const allTickets = [
|
||||||
|
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
|
||||||
|
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
|
||||||
|
];
|
||||||
|
|
||||||
|
if (allTickets.length === 0) {
|
||||||
|
return doSingleCveDelete(req, res, id, cve);
|
||||||
|
}
|
||||||
|
|
||||||
|
const likeConditions = allTickets.map(() => 'ci.extra_json LIKE ?');
|
||||||
|
const likeParams = allTickets.map(t => `%${t.key}%`);
|
||||||
|
|
||||||
|
db.all(
|
||||||
|
`SELECT ci.id, ci.extra_json FROM compliance_items ci
|
||||||
|
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||||
|
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
|
||||||
|
likeParams,
|
||||||
|
(compErr, compLinks) => {
|
||||||
|
if (compErr && compErr.message && compErr.message.includes('no such table')) { compLinks = []; }
|
||||||
|
else if (compErr) { console.error(compErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
const hasLink = (compLinks || []).some(cl => {
|
||||||
|
const json = cl.extra_json || '';
|
||||||
|
return allTickets.some(t => json.includes(t.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasLink) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'CVE deletion blocked: associated ticket linked to compliance report',
|
||||||
|
cascade_impact: { blocked: true, blocked_reason: 'Associated ticket is linked to a compliance report' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return doSingleCveDelete(req, res, id, cve);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
doSingleCveDelete(req, res, id, cve);
|
||||||
|
});
|
||||||
|
|
||||||
|
function doSingleCveDelete(req, res, id, cve) {
|
||||||
// Delete associated documents from DB
|
// Delete associated documents from DB
|
||||||
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
|
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
|
||||||
if (docErr) console.error('Error fetching documents:', docErr);
|
if (docErr) console.error('Error fetching documents:', docErr);
|
||||||
@@ -892,7 +945,7 @@ app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_Use
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== DOCUMENT ENDPOINTS ==========
|
// ========== DOCUMENT ENDPOINTS ==========
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
|
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const EXC_PATTERN = /EXC-\d+/i;
|
const EXC_PATTERN = /EXC-\d+/i;
|
||||||
@@ -217,6 +218,7 @@ function Toggle({ label, checked, onChange, color, colorRgb }) {
|
|||||||
// Main page
|
// Main page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default function ExportsPage() {
|
export default function ExportsPage() {
|
||||||
|
const { canExport } = useAuth();
|
||||||
const [loading, setLoading] = useState(null);
|
const [loading, setLoading] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [cveStatus, setCveStatus] = useState('');
|
const [cveStatus, setCveStatus] = useState('');
|
||||||
@@ -333,6 +335,15 @@ export default function ExportsPage() {
|
|||||||
|
|
||||||
// ---- Render ----
|
// ---- Render ----
|
||||||
|
|
||||||
|
if (!canExport()) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '4rem 1rem', color: '#94A3B8' }}>
|
||||||
|
<Shield style={{ width: '48px', height: '48px', margin: '0 auto 1rem', opacity: 0.5 }} />
|
||||||
|
<p style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>You do not have permission to export data.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user