Fix: Enable multi-vendor support for CVE entries
- Changed CVEs table constraint from UNIQUE(cve_id) to UNIQUE(cve_id, vendor) - Added vendor column to documents table for proper file organization - Updated backend INSERT statements to include vendor field in both CVE and document creation - Fixed document retrieval to filter by vendor - Created corrected setup.js that includes multi-vendor support from initial setup - Added migration scripts for existing databases Resolves #1: Users can now add the same CVE-ID with multiple different vendors, each maintaining separate document storage organized as CVE-ID/Vendor/files
This commit is contained in:
@@ -11,9 +11,15 @@ const fs = require('fs');
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3001;
|
const PORT = 3001;
|
||||||
|
|
||||||
|
// Log all incoming requests
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: ['http://localhost:3000', 'http://71.85.90.6:3000'],
|
origin: ['http://localhost:3000', 'http://192.168.2.117:3000'],
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -163,6 +169,10 @@ app.get('/api/cves/:cveId/vendors', (req, res) => {
|
|||||||
|
|
||||||
// Create new CVE entry - ALLOW MULTIPLE VENDORS
|
// Create new CVE entry - ALLOW MULTIPLE VENDORS
|
||||||
app.post('/api/cves', (req, res) => {
|
app.post('/api/cves', (req, res) => {
|
||||||
|
console.log('=== ADD CVE REQUEST ===');
|
||||||
|
console.log('Body:', req.body);
|
||||||
|
console.log('=======================');
|
||||||
|
|
||||||
const { cve_id, vendor, severity, description, published_date } = req.body;
|
const { cve_id, vendor, severity, description, published_date } = req.body;
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
@@ -170,8 +180,13 @@ app.post('/api/cves', (req, res) => {
|
|||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
console.log('Query:', query);
|
||||||
|
console.log('Values:', [cve_id, vendor, severity, description, published_date]);
|
||||||
|
|
||||||
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) {
|
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
console.error('DATABASE ERROR:', err); // Make sure this is here
|
||||||
|
// ... rest of error handling
|
||||||
// Check if it's a duplicate CVE_ID + Vendor combination
|
// Check if it's a duplicate CVE_ID + Vendor combination
|
||||||
if (err.message.includes('UNIQUE constraint failed')) {
|
if (err.message.includes('UNIQUE constraint failed')) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
@@ -196,7 +211,8 @@ app.patch('/api/cves/:cveId/status', (req, res) => {
|
|||||||
|
|
||||||
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
|
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
|
||||||
|
|
||||||
db.run(query, [status, cveId], function(err) {
|
db.run(query, [
|
||||||
|
vendor,status, cveId], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
@@ -229,66 +245,82 @@ app.get('/api/cves/:cveId/documents', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload document
|
// Upload document - ADD ERROR HANDLING FOR MULTER
|
||||||
app.post('/api/cves/:cveId/documents', upload.single('file'), (req, res) => {
|
app.post('/api/cves/:cveId/documents', (req, res, next) => {
|
||||||
const { cveId } = req.params;
|
upload.single('file')(req, res, (err) => {
|
||||||
const { type, notes, vendor } = req.body;
|
|
||||||
const file = req.file;
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!vendor) {
|
|
||||||
return res.status(400).json({ error: 'Vendor is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move file from temp to proper location
|
|
||||||
const finalDir = path.join('uploads', cveId, vendor);
|
|
||||||
if (!fs.existsSync(finalDir)) {
|
|
||||||
fs.mkdirSync(finalDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalPath = path.join(finalDir, file.filename);
|
|
||||||
|
|
||||||
// Move file from temp to final location
|
|
||||||
fs.renameSync(file.path, finalPath);
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
INSERT INTO documents (cve_id, name, type, file_path, file_size, mime_type, notes)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`;
|
|
||||||
|
|
||||||
const fileSizeKB = (file.size / 1024).toFixed(2) + ' KB';
|
|
||||||
|
|
||||||
db.run(query, [
|
|
||||||
cveId,
|
|
||||||
file.originalname,
|
|
||||||
type,
|
|
||||||
finalPath,
|
|
||||||
fileSizeKB,
|
|
||||||
file.mimetype,
|
|
||||||
notes
|
|
||||||
], function(err) {
|
|
||||||
if (err) {
|
if (err) {
|
||||||
// If database insert fails, delete the file
|
console.error('MULTER ERROR:', err);
|
||||||
if (fs.existsSync(finalPath)) {
|
return res.status(500).json({ error: 'File upload failed: ' + err.message });
|
||||||
fs.unlinkSync(finalPath);
|
|
||||||
}
|
|
||||||
return res.status(500).json({ error: err.message });
|
|
||||||
}
|
}
|
||||||
res.json({
|
|
||||||
id: this.lastID,
|
console.log('=== UPLOAD REQUEST RECEIVED ===');
|
||||||
message: 'Document uploaded successfully',
|
console.log('CVE ID:', req.params.cveId);
|
||||||
file: {
|
console.log('Body:', req.body);
|
||||||
name: file.originalname,
|
console.log('File:', req.file);
|
||||||
path: finalPath,
|
console.log('================================');
|
||||||
size: fileSizeKB
|
|
||||||
|
const { cveId } = req.params;
|
||||||
|
const { type, notes, vendor } = req.body;
|
||||||
|
const file = req.file;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
console.error('ERROR: No file uploaded');
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vendor) {
|
||||||
|
console.error('ERROR: Vendor is required');
|
||||||
|
return res.status(400).json({ error: 'Vendor is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move file from temp to proper location
|
||||||
|
const finalDir = path.join('uploads', cveId, vendor);
|
||||||
|
if (!fs.existsSync(finalDir)) {
|
||||||
|
fs.mkdirSync(finalDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalPath = path.join(finalDir, file.filename);
|
||||||
|
|
||||||
|
// Move file from temp to final location
|
||||||
|
fs.renameSync(file.path, finalPath);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO documents (cve_id, vendor, name, type, file_path, file_size, mime_type, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fileSizeKB = (file.size / 1024).toFixed(2) + ' KB';
|
||||||
|
|
||||||
|
db.run(query, [
|
||||||
|
cveId,
|
||||||
|
vendor,
|
||||||
|
file.originalname,
|
||||||
|
type,
|
||||||
|
finalPath,
|
||||||
|
fileSizeKB,
|
||||||
|
file.mimetype,
|
||||||
|
notes
|
||||||
|
], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('DATABASE ERROR:', err);
|
||||||
|
// If database insert fails, delete the file
|
||||||
|
if (fs.existsSync(finalPath)) {
|
||||||
|
fs.unlinkSync(finalPath);
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
res.json({
|
||||||
|
id: this.lastID,
|
||||||
|
message: 'Document uploaded successfully',
|
||||||
|
file: {
|
||||||
|
name: file.originalname,
|
||||||
|
path: finalPath,
|
||||||
|
size: fileSizeKB
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete document
|
// Delete document
|
||||||
app.delete('/api/documents/:id', (req, res) => {
|
app.delete('/api/documents/:id', (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Setup Script for CVE Database
|
// Setup Script for CVE Database
|
||||||
// This creates a fresh database ready for new CVE entries
|
// This creates a fresh database with multi-vendor support built-in
|
||||||
|
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -18,19 +18,21 @@ function initializeDatabase() {
|
|||||||
const schema = `
|
const schema = `
|
||||||
CREATE TABLE IF NOT EXISTS cves (
|
CREATE TABLE IF NOT EXISTS cves (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
cve_id VARCHAR(20) UNIQUE NOT NULL,
|
cve_id VARCHAR(20) NOT NULL,
|
||||||
vendor VARCHAR(100) NOT NULL,
|
vendor VARCHAR(100) NOT NULL,
|
||||||
severity VARCHAR(20) NOT NULL,
|
severity VARCHAR(20) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
published_date DATE,
|
published_date DATE,
|
||||||
status VARCHAR(50) DEFAULT 'Open',
|
status VARCHAR(50) DEFAULT 'Open',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(cve_id, vendor)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS documents (
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
cve_id VARCHAR(20) NOT NULL,
|
cve_id VARCHAR(20) NOT NULL,
|
||||||
|
vendor VARCHAR(100) NOT NULL,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
type VARCHAR(50) NOT NULL,
|
type VARCHAR(50) NOT NULL,
|
||||||
file_path VARCHAR(500) NOT NULL,
|
file_path VARCHAR(500) NOT NULL,
|
||||||
@@ -54,6 +56,7 @@ function initializeDatabase() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity);
|
CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity);
|
||||||
CREATE INDEX IF NOT EXISTS idx_status ON cves(status);
|
CREATE INDEX IF NOT EXISTS idx_status ON cves(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_doc_cve_id ON documents(cve_id);
|
CREATE INDEX IF NOT EXISTS idx_doc_cve_id ON documents(cve_id);
|
||||||
|
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);
|
||||||
|
|
||||||
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
|
||||||
@@ -66,6 +69,7 @@ function initializeDatabase() {
|
|||||||
|
|
||||||
CREATE VIEW IF NOT EXISTS cve_document_status AS
|
CREATE VIEW IF NOT EXISTS cve_document_status AS
|
||||||
SELECT
|
SELECT
|
||||||
|
c.id as record_id,
|
||||||
c.cve_id,
|
c.cve_id,
|
||||||
c.vendor,
|
c.vendor,
|
||||||
c.severity,
|
c.severity,
|
||||||
@@ -80,8 +84,8 @@ function initializeDatabase() {
|
|||||||
ELSE 'Missing Required Docs'
|
ELSE 'Missing Required Docs'
|
||||||
END as compliance_status
|
END as compliance_status
|
||||||
FROM cves c
|
FROM cves c
|
||||||
LEFT JOIN documents d ON c.cve_id = d.cve_id
|
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
||||||
GROUP BY c.cve_id, c.vendor, c.severity, c.status;
|
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
db.exec(schema, (err) => {
|
db.exec(schema, (err) => {
|
||||||
@@ -107,17 +111,21 @@ function createUploadsDirectory() {
|
|||||||
|
|
||||||
// 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📝 Would you like to add sample CVE data for testing? (y/n)');
|
console.log('\n📝 Adding sample CVE data for testing...');
|
||||||
|
|
||||||
// For automated setup, we'll skip this. Uncomment the code below if you want samples.
|
|
||||||
|
|
||||||
/*
|
|
||||||
const sampleCVEs = [
|
const sampleCVEs = [
|
||||||
{
|
{
|
||||||
cve_id: 'CVE-2024-SAMPLE-1',
|
cve_id: 'CVE-2024-SAMPLE-1',
|
||||||
vendor: 'Microsoft',
|
vendor: 'Microsoft',
|
||||||
severity: 'Critical',
|
severity: 'Critical',
|
||||||
description: 'Sample vulnerability for testing',
|
description: 'Sample remote code execution vulnerability',
|
||||||
|
published_date: '2024-01-15'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cve_id: 'CVE-2024-SAMPLE-1',
|
||||||
|
vendor: 'Cisco',
|
||||||
|
severity: 'High',
|
||||||
|
description: 'Sample remote code execution vulnerability',
|
||||||
published_date: '2024-01-15'
|
published_date: '2024-01-15'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -131,16 +139,37 @@ async function addSampleData(db) {
|
|||||||
(err) => {
|
(err) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else {
|
else {
|
||||||
console.log(` ✓ Added sample CVE: ${cve.cve_id}`);
|
console.log(` ✓ Added sample: ${cve.cve_id} / ${cve.vendor}`);
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
console.log('ℹ️ Skipping sample data - you can add CVEs through the API or dashboard');
|
console.log('ℹ️ Sample data added - demonstrates multi-vendor support');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify database structure
|
||||||
|
async function verifySetup(db) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
db.get('SELECT sql FROM sqlite_master WHERE type="table" AND name="cves"', (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Warning: Could not verify setup:', err);
|
||||||
|
} else {
|
||||||
|
console.log('\n📋 CVEs table structure:');
|
||||||
|
console.log(row.sql);
|
||||||
|
|
||||||
|
// Check if UNIQUE constraint is correct
|
||||||
|
if (row.sql.includes('UNIQUE(cve_id, vendor)')) {
|
||||||
|
console.log('\n✅ Multi-vendor support: ENABLED');
|
||||||
|
} else {
|
||||||
|
console.log('\n⚠️ Warning: Multi-vendor constraint may not be set correctly');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display setup summary
|
// Display setup summary
|
||||||
@@ -151,33 +180,37 @@ function displaySummary() {
|
|||||||
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');
|
||||||
|
console.log(' ✓ Multi-vendor support with UNIQUE(cve_id, vendor)');
|
||||||
|
console.log(' ✓ Vendor column in documents table');
|
||||||
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('\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/');
|
||||||
console.log(' └── VendorName/');
|
console.log(' ├── Vendor1/');
|
||||||
console.log(' ├── advisory.pdf');
|
console.log(' │ ├── advisory.pdf');
|
||||||
console.log(' ├── email.pdf');
|
console.log(' │ └── screenshot.png');
|
||||||
console.log(' └── screenshot.png');
|
console.log(' └── Vendor2/');
|
||||||
|
console.log(' └── advisory.pdf');
|
||||||
console.log('\n🚀 Next steps:');
|
console.log('\n🚀 Next steps:');
|
||||||
console.log(' 1. Start the backend API:');
|
console.log(' 1. Start the backend API:');
|
||||||
console.log(' → cd backend && node server.js');
|
console.log(' → cd backend && node server.js');
|
||||||
console.log(' 2. Start the frontend:');
|
console.log(' 2. Start the frontend:');
|
||||||
console.log(' → cd frontend && npm start');
|
console.log(' → cd frontend && npm start');
|
||||||
console.log(' 3. Open http://localhost:3000');
|
console.log(' 3. Open http://localhost:3000');
|
||||||
console.log(' 4. Start adding CVEs and uploading documents!');
|
console.log(' 4. Start adding CVEs with multiple vendors!');
|
||||||
console.log('\n💡 Tips:');
|
console.log('\n💡 Key Features:');
|
||||||
console.log(' • Use the Quick Check to verify CVE status');
|
console.log(' • Add same CVE-ID with different vendors');
|
||||||
console.log(' • Upload documents through the dashboard');
|
console.log(' • Each vendor has separate document storage');
|
||||||
console.log(' • Documents are auto-organized by CVE ID → Vendor');
|
console.log(' • Quick Check shows all vendors for a CVE');
|
||||||
|
console.log(' • Document compliance tracking per vendor');
|
||||||
console.log(' • Required docs: Advisory (mandatory for most vendors)\n');
|
console.log(' • Required docs: Advisory (mandatory for most vendors)\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main execution
|
// Main execution
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🚀 CVE Database Setup\n');
|
console.log('🚀 CVE Database Setup (Multi-Vendor Support)\n');
|
||||||
console.log('════════════════════════════════════════\n');
|
console.log('════════════════════════════════════════\n');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -187,13 +220,16 @@ async function main() {
|
|||||||
// Initialize database
|
// Initialize database
|
||||||
const db = await initializeDatabase();
|
const db = await initializeDatabase();
|
||||||
|
|
||||||
// Optionally add sample data
|
// Add sample data
|
||||||
await addSampleData(db);
|
await addSampleData(db);
|
||||||
|
|
||||||
|
// Verify setup
|
||||||
|
await verifySetup(db);
|
||||||
|
|
||||||
// Close database connection
|
// Close database connection
|
||||||
db.close((err) => {
|
db.close((err) => {
|
||||||
if (err) console.error('Error closing database:', err);
|
if (err) console.error('Error closing database:', err);
|
||||||
else console.log('✓ Database connection closed');
|
else console.log('\n✓ Database connection closed');
|
||||||
|
|
||||||
// Display summary
|
// Display summary
|
||||||
displaySummary();
|
displaySummary();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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';
|
||||||
|
|
||||||
const API_BASE = 'http://71.85.90.6:3001/api';
|
const API_BASE = 'http://192.168.2.117:3001/api';
|
||||||
|
|
||||||
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
||||||
|
|
||||||
@@ -626,7 +626,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<a
|
<a
|
||||||
href={`http://71.85.90.6:3001/${doc.file_path}`}
|
href={`http://192.168.2.117:3001/${doc.file_path}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="px-3 py-1 text-sm text-[#0476D9] hover:bg-blue-50 rounded transition-colors border border-[#0476D9]"
|
className="px-3 py-1 text-sm text-[#0476D9] hover:bg-blue-50 rounded transition-colors border border-[#0476D9]"
|
||||||
|
|||||||
Reference in New Issue
Block a user