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:
jramos
2026-04-06 16:18:07 -06:00
parent 1ef57b0504
commit 73fd747576
19 changed files with 1171 additions and 149 deletions

View File

@@ -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">