added required code changes, components, and packages for login feature

This commit is contained in:
2026-01-28 14:36:33 -07:00
parent 1d2a6b2e72
commit da14c92d98
13 changed files with 1370 additions and 63 deletions

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ frontend.pid
# Temporary files # Temporary files
backend/uploads/temp/ backend/uploads/temp/
claude.md
claude_status.md
feature_request*.md

View File

@@ -0,0 +1,70 @@
// Authentication Middleware
// Require authenticated user
function requireAuth(db) {
return async (req, res, next) => {
const sessionId = req.cookies?.session_id;
if (!sessionId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const session = await new Promise((resolve, reject) => {
db.get(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, 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')`,
[sessionId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!session) {
return res.status(401).json({ error: 'Session expired or invalid' });
}
if (!session.is_active) {
return res.status(401).json({ error: 'Account is disabled' });
}
// Attach user to request
req.user = {
id: session.user_id,
username: session.username,
email: session.email,
role: session.role
};
next();
} catch (err) {
console.error('Auth middleware error:', err);
return res.status(500).json({ error: 'Authentication error' });
}
};
}
// Require specific role(s)
function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'Insufficient permissions',
required: allowedRoles,
current: req.user.role
});
}
next();
};
}
module.exports = { requireAuth, requireRole };

184
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,184 @@
// Authentication Routes
const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
function createAuthRouter(db) {
const router = express.Router();
// Login
router.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
try {
// Find user
const user = await new Promise((resolve, reject) => {
db.get(
'SELECT * FROM users WHERE username = ?',
[username],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!user) {
return res.status(401).json({ error: 'Invalid username or password' });
}
if (!user.is_active) {
return res.status(401).json({ error: 'Account is disabled' });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Generate session ID
const sessionId = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Create session
await new Promise((resolve, reject) => {
db.run(
'INSERT INTO sessions (session_id, user_id, expires_at) VALUES (?, ?, ?)',
[sessionId, user.id, expiresAt.toISOString()],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
// Update last login
await new Promise((resolve, reject) => {
db.run(
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
[user.id],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
// Set cookie
res.cookie('session_id', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
res.json({
message: 'Login successful',
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role
}
});
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Login failed' });
}
});
// Logout
router.post('/logout', async (req, res) => {
const sessionId = req.cookies?.session_id;
if (sessionId) {
// Delete session from database
await new Promise((resolve) => {
db.run(
'DELETE FROM sessions WHERE session_id = ?',
[sessionId],
() => resolve()
);
});
}
// Clear cookie
res.clearCookie('session_id');
res.json({ message: 'Logged out successfully' });
});
// Get current user
router.get('/me', async (req, res) => {
const sessionId = req.cookies?.session_id;
if (!sessionId) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
const session = await new Promise((resolve, reject) => {
db.get(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, 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')`,
[sessionId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!session) {
res.clearCookie('session_id');
return res.status(401).json({ error: 'Session expired' });
}
if (!session.is_active) {
res.clearCookie('session_id');
return res.status(401).json({ error: 'Account is disabled' });
}
res.json({
user: {
id: session.user_id,
username: session.username,
email: session.email,
role: session.role
}
});
} catch (err) {
console.error('Get user error:', err);
res.status(500).json({ error: 'Failed to get user' });
}
});
// Clean up expired sessions (can be called periodically)
router.post('/cleanup-sessions', async (req, res) => {
try {
await new Promise((resolve, reject) => {
db.run(
"DELETE FROM sessions WHERE expires_at < datetime('now')",
(err) => {
if (err) reject(err);
else resolve();
}
);
});
res.json({ message: 'Expired sessions cleaned up' });
} catch (err) {
console.error('Session cleanup error:', err);
res.status(500).json({ error: 'Cleanup failed' });
}
});
return router;
}
module.exports = createAuthRouter;

217
backend/routes/users.js Normal file
View File

@@ -0,0 +1,217 @@
// User Management Routes (Admin only)
const express = require('express');
const bcrypt = require('bcryptjs');
function createUsersRouter(db, requireAuth, requireRole) {
const router = express.Router();
// All routes require admin role
router.use(requireAuth(db), requireRole('admin'));
// Get all users
router.get('/', async (req, res) => {
try {
const users = await new Promise((resolve, reject) => {
db.all(
`SELECT id, username, email, role, is_active, created_at, last_login
FROM users ORDER BY created_at DESC`,
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
res.json(users);
} catch (err) {
console.error('Get users error:', err);
res.status(500).json({ error: 'Failed to fetch users' });
}
});
// Get single user
router.get('/:id', async (req, res) => {
try {
const user = await new Promise((resolve, reject) => {
db.get(
`SELECT id, username, email, role, is_active, created_at, last_login
FROM users WHERE id = ?`,
[req.params.id],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (err) {
console.error('Get user error:', err);
res.status(500).json({ error: 'Failed to fetch user' });
}
});
// Create new user
router.post('/', async (req, res) => {
const { username, email, password, role } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' });
}
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Invalid role. Must be admin, editor, or viewer' });
}
try {
const passwordHash = await bcrypt.hash(password, 10);
const result = await new Promise((resolve, reject) => {
db.run(
`INSERT INTO users (username, email, password_hash, role)
VALUES (?, ?, ?, ?)`,
[username, email, passwordHash, role || 'viewer'],
function(err) {
if (err) reject(err);
else resolve({ id: this.lastID });
}
);
});
res.status(201).json({
message: 'User created successfully',
user: {
id: result.id,
username,
email,
role: role || 'viewer'
}
});
} catch (err) {
console.error('Create user error:', err);
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Username or email already exists' });
}
res.status(500).json({ error: 'Failed to create user' });
}
});
// Update user
router.patch('/:id', async (req, res) => {
const { username, email, password, role, is_active } = req.body;
const userId = req.params.id;
// Prevent self-demotion from admin
if (userId == req.user.id && role && role !== 'admin') {
return res.status(400).json({ error: 'Cannot remove your own admin role' });
}
// Prevent self-deactivation
if (userId == req.user.id && is_active === false) {
return res.status(400).json({ error: 'Cannot deactivate your own account' });
}
try {
const updates = [];
const values = [];
if (username) {
updates.push('username = ?');
values.push(username);
}
if (email) {
updates.push('email = ?');
values.push(email);
}
if (password) {
const passwordHash = await bcrypt.hash(password, 10);
updates.push('password_hash = ?');
values.push(passwordHash);
}
if (role) {
if (!['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
updates.push('role = ?');
values.push(role);
}
if (typeof is_active === 'boolean') {
updates.push('is_active = ?');
values.push(is_active ? 1 : 0);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
values.push(userId);
await new Promise((resolve, reject) => {
db.run(
`UPDATE users SET ${updates.join(', ')} WHERE id = ?`,
values,
function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
}
);
});
// If user was deactivated, delete their sessions
if (is_active === false) {
await new Promise((resolve) => {
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
});
}
res.json({ message: 'User updated successfully' });
} catch (err) {
console.error('Update user error:', err);
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Username or email already exists' });
}
res.status(500).json({ error: 'Failed to update user' });
}
});
// Delete user
router.delete('/:id', async (req, res) => {
const userId = req.params.id;
// Prevent self-deletion
if (userId == req.user.id) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
try {
// Delete sessions first (foreign key)
await new Promise((resolve) => {
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
});
// Delete user
const result = await new Promise((resolve, reject) => {
db.run('DELETE FROM users WHERE id = ?', [userId], function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
});
});
if (result.changes === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ message: 'User deleted successfully' });
} catch (err) {
console.error('Delete user error:', err);
res.status(500).json({ error: 'Failed to delete user' });
}
});
return router;
}
module.exports = createUsersRouter;

View File

@@ -1,5 +1,5 @@
// CVE Management Backend API // CVE Management Backend API
// Install: npm install express sqlite3 multer cors dotenv // Install: npm install express sqlite3 multer cors dotenv bcryptjs cookie-parser
require('dotenv').config(); require('dotenv').config();
@@ -7,12 +7,19 @@ const express = require('express');
const sqlite3 = require('sqlite3').verbose(); const sqlite3 = require('sqlite3').verbose();
const multer = require('multer'); const multer = require('multer');
const cors = require('cors'); const cors = require('cors');
const cookieParser = require('cookie-parser');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
// Auth imports
const { requireAuth, requireRole } = require('./middleware/auth');
const createAuthRouter = require('./routes/auth');
const createUsersRouter = require('./routes/users');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const API_HOST = process.env.API_HOST || 'localhost'; const API_HOST = process.env.API_HOST || 'localhost';
const SESSION_SECRET = process.env.SESSION_SECRET || 'default-secret-change-me';
const CORS_ORIGINS = process.env.CORS_ORIGINS const CORS_ORIGINS = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',') ? process.env.CORS_ORIGINS.split(',')
: ['http://localhost:3000']; : ['http://localhost:3000'];
@@ -29,6 +36,7 @@ app.use(cors({
credentials: true credentials: true
})); }));
app.use(express.json()); app.use(express.json());
app.use(cookieParser());
app.use('/uploads', express.static('uploads')); app.use('/uploads', express.static('uploads'));
// Database connection // Database connection
@@ -37,6 +45,12 @@ const db = new sqlite3.Database('./cve_database.db', (err) => {
else console.log('Connected to CVE database'); else console.log('Connected to CVE database');
}); });
// Auth routes (public)
app.use('/api/auth', createAuthRouter(db));
// User management routes (admin only)
app.use('/api/users', createUsersRouter(db, requireAuth, requireRole));
// Simple storage - upload to temp directory first // Simple storage - upload to temp directory first
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
@@ -59,8 +73,8 @@ const upload = multer({
// ========== CVE ENDPOINTS ========== // ========== CVE ENDPOINTS ==========
// Get all CVEs with optional filters // Get all CVEs with optional filters (authenticated users)
app.get('/api/cves', (req, res) => { app.get('/api/cves', requireAuth(db), (req, res) => {
const { search, vendor, severity, status } = req.query; const { search, vendor, severity, status } = req.query;
let query = ` let query = `
@@ -106,8 +120,8 @@ app.get('/api/cves', (req, res) => {
}); });
}); });
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR // Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users)
app.get('/api/cves/check/:cveId', (req, res) => { app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
const { cveId } = req.params; const { cveId } = req.params;
const query = ` const query = `
@@ -153,8 +167,8 @@ app.get('/api/cves/check/:cveId', (req, res) => {
}); });
}); });
// NEW ENDPOINT: Get all vendors for a specific CVE // NEW ENDPOINT: Get all vendors for a specific CVE (authenticated users)
app.get('/api/cves/:cveId/vendors', (req, res) => { app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
const { cveId } = req.params; const { cveId } = req.params;
const query = ` const query = `
@@ -173,8 +187,8 @@ app.get('/api/cves/:cveId/vendors', (req, res) => {
}); });
// Create new CVE entry - ALLOW MULTIPLE VENDORS // Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
app.post('/api/cves', (req, res) => { app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
console.log('=== ADD CVE REQUEST ==='); console.log('=== ADD CVE REQUEST ===');
console.log('Body:', req.body); console.log('Body:', req.body);
console.log('======================='); console.log('=======================');
@@ -210,8 +224,8 @@ app.post('/api/cves', (req, res) => {
}); });
// Update CVE status // Update CVE status (editor or admin)
app.patch('/api/cves/:cveId/status', (req, res) => { app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
const { cveId } = req.params; const { cveId } = req.params;
const { status } = req.body; const { status } = req.body;
@@ -228,8 +242,8 @@ app.patch('/api/cves/:cveId/status', (req, res) => {
// ========== DOCUMENT ENDPOINTS ========== // ========== DOCUMENT ENDPOINTS ==========
// Get documents for a CVE - FILTER BY VENDOR // Get documents for a CVE - FILTER BY VENDOR (authenticated users)
app.get('/api/cves/:cveId/documents', (req, res) => { app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
const { cveId } = req.params; const { cveId } = req.params;
const { vendor } = req.query; // NEW: Optional vendor filter const { vendor } = req.query; // NEW: Optional vendor filter
@@ -251,8 +265,8 @@ app.get('/api/cves/:cveId/documents', (req, res) => {
}); });
}); });
// Upload document - ADD ERROR HANDLING FOR MULTER // Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin)
app.post('/api/cves/:cveId/documents', (req, res, next) => { app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => {
upload.single('file')(req, res, (err) => { upload.single('file')(req, res, (err) => {
if (err) { if (err) {
console.error('MULTER ERROR:', err); console.error('MULTER ERROR:', err);
@@ -327,8 +341,8 @@ app.post('/api/cves/:cveId/documents', (req, res, next) => {
}); });
}); });
}); });
// Delete document // Delete document (admin only)
app.delete('/api/documents/:id', (req, res) => { app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, res) => {
const { id } = req.params; const { id } = req.params;
// First get the file path to delete the actual file // First get the file path to delete the actual file
@@ -352,8 +366,8 @@ app.delete('/api/documents/:id', (req, res) => {
// ========== UTILITY ENDPOINTS ========== // ========== UTILITY ENDPOINTS ==========
// Get all vendors // Get all vendors (authenticated users)
app.get('/api/vendors', (req, res) => { app.get('/api/vendors', requireAuth(db), (req, res) => {
const query = `SELECT DISTINCT vendor FROM cves ORDER BY vendor`; const query = `SELECT DISTINCT vendor FROM cves ORDER BY vendor`;
db.all(query, [], (err, rows) => { db.all(query, [], (err, rows) => {
@@ -364,8 +378,8 @@ app.get('/api/vendors', (req, res) => {
}); });
}); });
// Get statistics // Get statistics (authenticated users)
app.get('/api/stats', (req, res) => { app.get('/api/stats', requireAuth(db), (req, res) => {
const query = ` const query = `
SELECT SELECT
COUNT(DISTINCT c.id) as total_cves, COUNT(DISTINCT c.id) as total_cves,

View File

@@ -2,6 +2,7 @@
// This creates a fresh database with multi-vendor support built-in // This creates a fresh database with multi-vendor support built-in
const sqlite3 = require('sqlite3').verbose(); const sqlite3 = require('sqlite3').verbose();
const bcrypt = require('bcryptjs');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@@ -59,6 +60,34 @@ function initializeDatabase() {
CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor); CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor);
CREATE INDEX IF NOT EXISTS idx_doc_type ON documents(type); CREATE INDEX IF NOT EXISTS idx_doc_type ON documents(type);
-- Users table for authentication
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP,
CHECK (role IN ('admin', 'editor', 'viewer'))
);
-- Sessions table for session management
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id VARCHAR(255) UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
INSERT OR IGNORE INTO required_documents (vendor, document_type, is_mandatory, description) VALUES INSERT OR IGNORE INTO required_documents (vendor, document_type, is_mandatory, description) VALUES
('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'), ('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'),
('Microsoft', 'screenshot', 0, 'Proof of patch application'), ('Microsoft', 'screenshot', 0, 'Proof of patch application'),
@@ -109,6 +138,42 @@ function createUploadsDirectory() {
} }
} }
// Create default admin user
async function createDefaultAdmin(db) {
return new Promise((resolve, reject) => {
// Check if admin already exists
db.get('SELECT id FROM users WHERE username = ?', ['admin'], async (err, row) => {
if (err) {
reject(err);
return;
}
if (row) {
console.log('✓ Default admin user already exists');
resolve();
return;
}
// Create admin user with password 'admin123'
const passwordHash = await bcrypt.hash('admin123', 10);
db.run(
`INSERT INTO users (username, email, password_hash, role, is_active)
VALUES (?, ?, ?, ?, ?)`,
['admin', 'admin@localhost', passwordHash, 'admin', 1],
(err) => {
if (err) {
reject(err);
} else {
console.log('✓ Created default admin user (admin/admin123)');
resolve();
}
}
);
});
});
}
// Add sample CVE data (optional - for testing) // Add sample CVE data (optional - for testing)
async function addSampleData(db) { async function addSampleData(db) {
console.log('\n📝 Adding sample CVE data for testing...'); console.log('\n📝 Adding sample CVE data for testing...');
@@ -179,12 +244,14 @@ function displaySummary() {
console.log('╚════════════════════════════════════════════════════════╝'); console.log('╚════════════════════════════════════════════════════════╝');
console.log('\n📊 What was created:'); console.log('\n📊 What was created:');
console.log(' ✓ SQLite database (cve_database.db)'); console.log(' ✓ SQLite database (cve_database.db)');
console.log(' ✓ Tables: cves, documents, required_documents'); console.log(' ✓ Tables: cves, documents, required_documents, users, sessions');
console.log(' ✓ Multi-vendor support with UNIQUE(cve_id, vendor)'); console.log(' ✓ Multi-vendor support with UNIQUE(cve_id, vendor)');
console.log(' ✓ Vendor column in documents table'); console.log(' ✓ Vendor column in documents table');
console.log(' ✓ User authentication with session-based auth');
console.log(' ✓ Indexes for fast queries'); console.log(' ✓ Indexes for fast queries');
console.log(' ✓ Document compliance view'); console.log(' ✓ Document compliance view');
console.log(' ✓ Uploads directory for file storage'); console.log(' ✓ Uploads directory for file storage');
console.log(' ✓ Default admin user (admin/admin123)');
console.log('\n📁 File structure will be:'); console.log('\n📁 File structure will be:');
console.log(' uploads/'); console.log(' uploads/');
console.log(' └── CVE-XXXX-XXXX/'); console.log(' └── CVE-XXXX-XXXX/');
@@ -220,6 +287,9 @@ async function main() {
// Initialize database // Initialize database
const db = await initializeDatabase(); const db = await initializeDatabase();
// Create default admin user
await createDefaultAdmin(db);
// Add sample data // Add sample data
await addSampleData(db); await addSampleData(db);

View File

@@ -1,5 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus } from 'lucide-react'; import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus } from 'lucide-react';
import { useAuth } from './contexts/AuthContext';
import LoginForm from './components/LoginForm';
import UserMenu from './components/UserMenu';
import UserManagement from './components/UserManagement';
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 API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001'; const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
@@ -7,6 +11,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low']; const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
export default function App() { export default function App() {
const { isAuthenticated, loading: authLoading, canWrite, isAdmin } = useAuth();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedVendor, setSelectedVendor] = useState('All Vendors'); const [selectedVendor, setSelectedVendor] = useState('All Vendors');
const [selectedSeverity, setSelectedSeverity] = useState('All Severities'); const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
@@ -21,6 +26,7 @@ export default function App() {
const [quickCheckCVE, setQuickCheckCVE] = useState(''); const [quickCheckCVE, setQuickCheckCVE] = useState('');
const [quickCheckResult, setQuickCheckResult] = useState(null); const [quickCheckResult, setQuickCheckResult] = useState(null);
const [showAddCVE, setShowAddCVE] = useState(false); const [showAddCVE, setShowAddCVE] = useState(false);
const [showUserManagement, setShowUserManagement] = useState(false);
const [newCVE, setNewCVE] = useState({ const [newCVE, setNewCVE] = useState({
cve_id: '', cve_id: '',
vendor: '', vendor: '',
@@ -30,19 +36,6 @@ export default function App() {
}); });
const [uploadingFile, setUploadingFile] = useState(false); const [uploadingFile, setUploadingFile] = useState(false);
// Fetch CVEs from API
useEffect(() => {
fetchCVEs();
fetchVendors();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Refetch when filters change
useEffect(() => {
fetchCVEs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedVendor, selectedSeverity]);
const fetchCVEs = async () => { const fetchCVEs = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -52,7 +45,9 @@ export default function App() {
if (selectedVendor !== 'All Vendors') params.append('vendor', selectedVendor); if (selectedVendor !== 'All Vendors') params.append('vendor', selectedVendor);
if (selectedSeverity !== 'All Severities') params.append('severity', selectedSeverity); if (selectedSeverity !== 'All Severities') params.append('severity', selectedSeverity);
const response = await fetch(`${API_BASE}/cves?${params}`); const response = await fetch(`${API_BASE}/cves?${params}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch CVEs'); if (!response.ok) throw new Error('Failed to fetch CVEs');
const data = await response.json(); const data = await response.json();
setCves(data); setCves(data);
@@ -66,7 +61,9 @@ export default function App() {
const fetchVendors = async () => { const fetchVendors = async () => {
try { try {
const response = await fetch(`${API_BASE}/vendors`); const response = await fetch(`${API_BASE}/vendors`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch vendors'); if (!response.ok) throw new Error('Failed to fetch vendors');
const data = await response.json(); const data = await response.json();
setVendors(['All Vendors', ...data]); setVendors(['All Vendors', ...data]);
@@ -80,7 +77,9 @@ export default function App() {
if (cveDocuments[key]) return; if (cveDocuments[key]) return;
try { try {
const response = await fetch(`${API_BASE}/cves/${cveId}/documents?vendor=${vendor}`); const response = await fetch(`${API_BASE}/cves/${cveId}/documents?vendor=${vendor}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch documents'); if (!response.ok) throw new Error('Failed to fetch documents');
const data = await response.json(); const data = await response.json();
setCveDocuments(prev => ({ ...prev, [key]: data })); setCveDocuments(prev => ({ ...prev, [key]: data }));
@@ -93,7 +92,9 @@ export default function App() {
if (!quickCheckCVE.trim()) return; if (!quickCheckCVE.trim()) return;
try { try {
const response = await fetch(`${API_BASE}/cves/check/${quickCheckCVE.trim()}`); const response = await fetch(`${API_BASE}/cves/check/${quickCheckCVE.trim()}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to check CVE'); if (!response.ok) throw new Error('Failed to check CVE');
const data = await response.json(); const data = await response.json();
setQuickCheckResult(data); setQuickCheckResult(data);
@@ -104,7 +105,6 @@ export default function App() {
}; };
const handleViewDocuments = async (cveId, vendor) => { const handleViewDocuments = async (cveId, vendor) => {
const key = `${cveId}-${vendor}`;
if (selectedCVE === cveId && selectedVendorView === vendor) { if (selectedCVE === cveId && selectedVendorView === vendor) {
setSelectedCVE(null); setSelectedCVE(null);
setSelectedVendorView(null); setSelectedVendorView(null);
@@ -143,6 +143,7 @@ export default function App() {
const response = await fetch(`${API_BASE}/cves`, { const response = await fetch(`${API_BASE}/cves`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(newCVE) body: JSON.stringify(newCVE)
}); });
@@ -195,6 +196,7 @@ export default function App() {
try { try {
const response = await fetch(`${API_BASE}/cves/${cveId}/documents`, { const response = await fetch(`${API_BASE}/cves/${cveId}/documents`, {
method: 'POST', method: 'POST',
credentials: 'include',
body: formData body: formData
}); });
@@ -222,7 +224,8 @@ export default function App() {
try { try {
const response = await fetch(`${API_BASE}/documents/${docId}`, { const response = await fetch(`${API_BASE}/documents/${docId}`, {
method: 'DELETE' method: 'DELETE',
credentials: 'include'
}); });
if (!response.ok) throw new Error('Failed to delete document'); if (!response.ok) throw new Error('Failed to delete document');
@@ -237,6 +240,40 @@ export default function App() {
} }
}; };
// Fetch CVEs from API when authenticated
useEffect(() => {
if (isAuthenticated) {
fetchCVEs();
fetchVendors();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated]);
// Refetch when filters change
useEffect(() => {
if (isAuthenticated) {
fetchCVEs();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedVendor, selectedSeverity]);
// Show loading while checking auth
if (authLoading) {
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="text-center">
<Loader className="w-12 h-12 text-[#0476D9] mx-auto animate-spin" />
<p className="text-gray-600 mt-4">Loading...</p>
</div>
</div>
);
}
// Show login if not authenticated
if (!isAuthenticated) {
return <LoginForm />;
}
// Group CVEs by CVE ID // Group CVEs by CVE ID
const groupedCVEs = cves.reduce((acc, cve) => { const groupedCVEs = cves.reduce((acc, cve) => {
if (!acc[cve.cve_id]) { if (!acc[cve.cve_id]) {
@@ -257,6 +294,8 @@ export default function App() {
<h1 className="text-3xl font-bold text-gray-900 mb-2">CVE Dashboard</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2">CVE Dashboard</h1>
<p className="text-gray-600">Query vulnerabilities, manage vendors, and attach documentation</p> <p className="text-gray-600">Query vulnerabilities, manage vendors, and attach documentation</p>
</div> </div>
<div className="flex items-center gap-4">
{canWrite() && (
<button <button
onClick={() => setShowAddCVE(true)} onClick={() => setShowAddCVE(true)}
className="px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors flex items-center gap-2 shadow-md" className="px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors flex items-center gap-2 shadow-md"
@@ -264,7 +303,15 @@ export default function App() {
<Plus className="w-5 h-5" /> <Plus className="w-5 h-5" />
Add CVE/Vendor Add CVE/Vendor
</button> </button>
)}
<UserMenu onManageUsers={() => setShowUserManagement(true)} />
</div> </div>
</div>
{/* User Management Modal */}
{showUserManagement && (
<UserManagement onClose={() => setShowUserManagement(false)} />
)}
{/* Add CVE Modal */} {/* Add CVE Modal */}
{showAddCVE && ( {showAddCVE && (
@@ -634,6 +681,7 @@ export default function App() {
> >
View View
</a> </a>
{isAdmin() && (
<button <button
onClick={() => handleDeleteDocument(doc.id, cve.cve_id, cve.vendor)} onClick={() => handleDeleteDocument(doc.id, cve.cve_id, cve.vendor)}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors border border-red-600 flex items-center gap-1" className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors border border-red-600 flex items-center gap-1"
@@ -641,6 +689,7 @@ export default function App() {
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
Delete Delete
</button> </button>
)}
</div> </div>
</div> </div>
))} ))}
@@ -648,6 +697,7 @@ export default function App() {
) : ( ) : (
<p className="text-sm text-gray-500 italic">No documents attached yet</p> <p className="text-sm text-gray-500 italic">No documents attached yet</p>
)} )}
{canWrite() && (
<button <button
onClick={() => handleFileUpload(cve.cve_id, cve.vendor)} onClick={() => handleFileUpload(cve.cve_id, cve.vendor)}
disabled={uploadingFile} disabled={uploadingFile}
@@ -656,6 +706,7 @@ export default function App() {
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
{uploadingFile ? 'Uploading...' : 'Upload Document'} {uploadingFile ? 'Uploading...' : 'Upload Document'}
</button> </button>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { Loader, AlertCircle, Lock, User } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
export default function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await login(username, password);
if (!result.success) {
setError(result.error);
}
setLoading(false);
};
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-8">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-[#0476D9] rounded-full flex items-center justify-center mx-auto mb-4">
<Lock className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900">CVE Dashboard</h1>
<p className="text-gray-600 mt-2">Sign in to access the dashboard</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-700">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<div className="relative">
<User className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
id="username"
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
placeholder="Enter your username"
disabled={loading}
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<div className="relative">
<Lock className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
placeholder="Enter your password"
disabled={loading}
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<>
<Loader className="w-5 h-5 animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</button>
</form>
<div className="mt-6 pt-6 border-t border-gray-200">
<p className="text-sm text-gray-500 text-center">
Default admin credentials: admin / admin123
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,380 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
export default function UserManagement({ onClose }) {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showAddUser, setShowAddUser] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
role: 'viewer'
});
const [formError, setFormError] = useState('');
const [formSuccess, setFormSuccess] = useState('');
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const response = await fetch(`${API_BASE}/users`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch users');
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setFormError('');
setFormSuccess('');
try {
const url = editingUser
? `${API_BASE}/users/${editingUser.id}`
: `${API_BASE}/users`;
const method = editingUser ? 'PATCH' : 'POST';
const body = { ...formData };
if (editingUser && !body.password) {
delete body.password;
}
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Operation failed');
}
setFormSuccess(editingUser ? 'User updated successfully' : 'User created successfully');
fetchUsers();
setTimeout(() => {
setShowAddUser(false);
setEditingUser(null);
setFormData({ username: '', email: '', password: '', role: 'viewer' });
setFormSuccess('');
}, 1500);
} catch (err) {
setFormError(err.message);
}
};
const handleEdit = (user) => {
setEditingUser(user);
setFormData({
username: user.username,
email: user.email,
password: '',
role: user.role
});
setShowAddUser(true);
setFormError('');
setFormSuccess('');
};
const handleDelete = async (userId) => {
if (!window.confirm('Are you sure you want to delete this user?')) {
return;
}
try {
const response = await fetch(`${API_BASE}/users/${userId}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Delete failed');
}
fetchUsers();
} catch (err) {
alert(err.message);
}
};
const handleToggleActive = async (user) => {
try {
const response = await fetch(`${API_BASE}/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ is_active: !user.is_active })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Update failed');
}
fetchUsers();
} catch (err) {
alert(err.message);
}
};
const getRoleBadgeColor = (role) => {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-800';
case 'editor':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900">User Management</h2>
<p className="text-gray-600">Manage user accounts and permissions</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-2"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1">
{!showAddUser && (
<button
onClick={() => {
setShowAddUser(true);
setEditingUser(null);
setFormData({ username: '', email: '', password: '', role: 'viewer' });
setFormError('');
setFormSuccess('');
}}
className="mb-6 px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors flex items-center gap-2 shadow-md"
>
<Plus className="w-5 h-5" />
Add User
</button>
)}
{showAddUser && (
<div className="mb-6 p-6 bg-gray-50 rounded-lg border border-gray-200">
<h3 className="text-lg font-semibold mb-4">
{editingUser ? 'Edit User' : 'Add New User'}
</h3>
{formError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
<span className="text-sm text-red-700">{formError}</span>
</div>
)}
{formSuccess && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-sm text-green-700">{formSuccess}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<div className="relative">
<User className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<div className="relative">
<Mail className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password {editingUser ? '(leave blank to keep current)' : '*'}
</label>
<input
type="password"
required={!editingUser}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role *
</label>
<div className="relative">
<Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
>
<option value="viewer">Viewer (read-only)</option>
<option value="editor">Editor (can add CVEs, upload docs)</option>
<option value="admin">Admin (full access)</option>
</select>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
className="px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors"
>
{editingUser ? 'Update User' : 'Create User'}
</button>
<button
type="button"
onClick={() => {
setShowAddUser(false);
setEditingUser(null);
}}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
)}
{loading ? (
<div className="text-center py-12">
<Loader className="w-8 h-8 text-[#0476D9] mx-auto animate-spin" />
<p className="text-gray-600 mt-2">Loading users...</p>
</div>
) : error ? (
<div className="text-center py-12">
<AlertCircle className="w-8 h-8 text-red-500 mx-auto" />
<p className="text-red-600 mt-2">{error}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Role</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4">
<div>
<p className="font-medium text-gray-900">{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{user.role.charAt(0).toUpperCase() + user.role.slice(1)}
</span>
</td>
<td className="py-3 px-4">
<button
onClick={() => handleToggleActive(user)}
disabled={user.id === currentUser.id}
className={`px-2 py-1 rounded text-xs font-medium ${
user.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
} ${user.id === currentUser.id ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:opacity-80'}`}
>
{user.is_active ? 'Active' : 'Inactive'}
</button>
</td>
<td className="py-3 px-4 text-sm text-gray-500">
{user.last_login
? new Date(user.last_login).toLocaleString()
: 'Never'}
</td>
<td className="py-3 px-4">
<div className="flex justify-end gap-2">
<button
onClick={() => handleEdit(user)}
className="p-2 text-gray-600 hover:bg-gray-100 rounded"
title="Edit user"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(user.id)}
disabled={user.id === currentUser.id}
className={`p-2 text-red-600 hover:bg-red-50 rounded ${
user.id === currentUser.id ? 'opacity-50 cursor-not-allowed' : ''
}`}
title="Delete user"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import React, { useState, useRef, useEffect } from 'react';
import { User, LogOut, ChevronDown, Shield } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
export default function UserMenu({ onManageUsers }) {
const { user, logout, isAdmin } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef(null);
// Close menu when clicking outside
useEffect(() => {
function handleClickOutside(event) {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const getRoleBadgeColor = (role) => {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-800';
case 'editor':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const handleLogout = async () => {
setIsOpen(false);
await logout();
};
const handleManageUsers = () => {
setIsOpen(false);
if (onManageUsers) {
onManageUsers();
}
};
if (!user) return null;
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<div className="w-8 h-8 bg-[#0476D9] rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-white" />
</div>
<div className="text-left hidden sm:block">
<p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
</div>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
<span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{user.role.charAt(0).toUpperCase() + user.role.slice(1)}
</span>
</div>
{isAdmin() && (
<button
onClick={handleManageUsers}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
>
<Shield className="w-4 h-4" />
Manage Users
</button>
)}
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-3"
>
<LogOut className="w-4 h-4" />
Sign Out
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Check if user is authenticated on mount
const checkAuth = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/auth/me`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
} else {
setUser(null);
}
} catch (err) {
console.error('Auth check error:', err);
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
checkAuth();
}, [checkAuth]);
// Login function
const login = async (username, password) => {
setError(null);
try {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
setUser(data.user);
return { success: true };
} catch (err) {
setError(err.message);
return { success: false, error: err.message };
}
};
// Logout function
const logout = async () => {
try {
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
} catch (err) {
console.error('Logout error:', err);
}
setUser(null);
};
// Check if user has a specific role
const hasRole = (...roles) => {
return user && roles.includes(user.role);
};
// Check if user can perform write operations (editor or admin)
const canWrite = () => hasRole('editor', 'admin');
// Check if user is admin
const isAdmin = () => hasRole('admin');
const value = {
user,
loading,
error,
login,
logout,
checkAuth,
hasRole,
canWrite,
isAdmin,
isAuthenticated: !!user
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -3,11 +3,14 @@ import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
import { AuthProvider } from './contexts/AuthContext';
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<AuthProvider>
<App /> <App />
</AuthProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -10,6 +10,8 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"express": "^5.2.1", "express": "^5.2.1",