feat: implement group-based access control (Admin, Standard_User, Leadership, Read_Only)
- Add user_group migration and created_by column migration - Replace requireRole middleware with requireGroup - Update all backend routes to use group-based authorization - Add Standard_User conditional delete with ownership, state, and compliance checks - Add cascade impact check for CVE deletes - Update AuthContext with group-based permission helpers - Update all frontend components for group-based rendering - Update UserManagement UI with group dropdown, confirmation dialogs, self-demotion prevention
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-react';
|
||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||
@@ -9,7 +10,11 @@ const NAV_ITEMS = [
|
||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||
];
|
||||
|
||||
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
||||
|
||||
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -110,6 +115,60 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Admin panel link — visible only to Admin group */}
|
||||
{isAdmin() && (() => {
|
||||
const { id, label, icon: Icon, color, description } = ADMIN_ITEM;
|
||||
const active = currentPage === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => { onNavigate(id); onClose(); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.875rem',
|
||||
padding: '0.75rem 0.875rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: active ? `1px solid ${color}50` : '1px solid transparent',
|
||||
background: active ? `${color}18` : 'transparent',
|
||||
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||
marginTop: '0.5rem',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
paddingTop: '1rem',
|
||||
transition: 'background 0.15s, border-color 0.15s'
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{
|
||||
width: '36px', height: '36px', flexShrink: 0,
|
||||
borderRadius: '0.375rem',
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}40`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>
|
||||
<Icon style={{ width: '17px', height: '17px', color }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||
color: active ? color : '#CBD5E1',
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em'
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<div style={{
|
||||
width: '6px', height: '6px', borderRadius: '50%',
|
||||
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -4,6 +4,22 @@ import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||
|
||||
const GROUP_LABELS = {
|
||||
Admin: 'Admin (full access)',
|
||||
Standard_User: 'Standard User (create, edit, limited delete)',
|
||||
Leadership: 'Leadership (read-only + exports)',
|
||||
Read_Only: 'Read Only (view only)'
|
||||
};
|
||||
|
||||
const GROUP_BADGE_STYLES = {
|
||||
Admin: { backgroundColor: '#FEE2E2', color: '#991B1B' },
|
||||
Standard_User: { backgroundColor: '#DBEAFE', color: '#1E40AF' },
|
||||
Leadership: { backgroundColor: '#F3E8FF', color: '#6B21A8' },
|
||||
Read_Only: { backgroundColor: '#F3F4F6', color: '#374151' }
|
||||
};
|
||||
|
||||
export default function UserManagement({ onClose }) {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
@@ -15,7 +31,7 @@ export default function UserManagement({ onClose }) {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'viewer'
|
||||
group: 'Read_Only'
|
||||
});
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formSuccess, setFormSuccess] = useState('');
|
||||
@@ -39,11 +55,29 @@ export default function UserManagement({ onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmGroupChange = (targetUser, newGroup) => {
|
||||
let message = `Are you sure you want to change ${targetUser.username}'s group from ${targetUser.group} to ${newGroup}?`;
|
||||
|
||||
// Extra warning when downgrading an Admin user
|
||||
if (targetUser.group === 'Admin' && newGroup !== 'Admin') {
|
||||
message += `\n\n⚠️ WARNING: You are removing Admin privileges from ${targetUser.username}. They will lose full system access.`;
|
||||
}
|
||||
|
||||
return window.confirm(message);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setFormError('');
|
||||
setFormSuccess('');
|
||||
|
||||
// If editing and group changed, show confirmation dialog
|
||||
if (editingUser && formData.group !== editingUser.group) {
|
||||
if (!confirmGroupChange(editingUser, formData.group)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = editingUser
|
||||
? `${API_BASE}/users/${editingUser.id}`
|
||||
@@ -75,7 +109,7 @@ export default function UserManagement({ onClose }) {
|
||||
setTimeout(() => {
|
||||
setShowAddUser(false);
|
||||
setEditingUser(null);
|
||||
setFormData({ username: '', email: '', password: '', role: 'viewer' });
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
|
||||
setFormSuccess('');
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
@@ -89,7 +123,7 @@ export default function UserManagement({ onClose }) {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
role: user.role
|
||||
group: user.group
|
||||
});
|
||||
setShowAddUser(true);
|
||||
setFormError('');
|
||||
@@ -140,15 +174,10 @@ export default function UserManagement({ onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
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';
|
||||
}
|
||||
// Check if group dropdown should be disabled for self-demotion prevention
|
||||
const isGroupDropdownDisabled = (targetUser) => {
|
||||
if (!targetUser || !currentUser) return false;
|
||||
return targetUser.id === currentUser.id && currentUser.group === 'Admin';
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -173,7 +202,7 @@ export default function UserManagement({ onClose }) {
|
||||
onClick={() => {
|
||||
setShowAddUser(true);
|
||||
setEditingUser(null);
|
||||
setFormData({ username: '', email: '', password: '', role: 'viewer' });
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
|
||||
setFormError('');
|
||||
setFormSuccess('');
|
||||
}}
|
||||
@@ -253,19 +282,24 @@ export default function UserManagement({ onClose }) {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role *
|
||||
Group *
|
||||
</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"
|
||||
value={formData.group}
|
||||
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
|
||||
disabled={isGroupDropdownDisabled(editingUser)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={isGroupDropdownDisabled(editingUser) ? 'Cannot change your own Admin group' : ''}
|
||||
>
|
||||
<option value="viewer">Viewer (read-only)</option>
|
||||
<option value="editor">Editor (can add CVEs, upload docs)</option>
|
||||
<option value="admin">Admin (full access)</option>
|
||||
{VALID_GROUPS.map((g) => (
|
||||
<option key={g} value={g}>{GROUP_LABELS[g]}</option>
|
||||
))}
|
||||
</select>
|
||||
{isGroupDropdownDisabled(editingUser) && (
|
||||
<p className="text-xs text-amber-600 mt-1">You cannot change your own Admin group.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,7 +342,7 @@ export default function UserManagement({ onClose }) {
|
||||
<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">Group</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>
|
||||
@@ -324,8 +358,17 @@ export default function UserManagement({ onClose }) {
|
||||
</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
|
||||
style={{
|
||||
...GROUP_BADGE_STYLES[user.group] || GROUP_BADGE_STYLES.Read_Only,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
display: 'inline-block'
|
||||
}}
|
||||
>
|
||||
{user.group ? user.group.replace('_', ' ') : 'Read Only'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
|
||||
@@ -19,17 +19,26 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const getRoleBadgeColor = (role) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
const getGroupBadgeColor = (group) => {
|
||||
switch (group) {
|
||||
case 'Admin':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'editor':
|
||||
case 'Standard_User':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'Leadership':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'Read_Only':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const formatGroupName = (group) => {
|
||||
if (!group) return '';
|
||||
return group.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsOpen(false);
|
||||
await logout();
|
||||
@@ -62,7 +71,7 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
||||
</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>
|
||||
<p className="text-xs text-gray-500">{formatGroupName(user.group)}</p>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
@@ -72,8 +81,8 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
||||
<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 className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getGroupBadgeColor(user.group)}`}>
|
||||
{formatGroupName(user.group)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user