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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user