Each user can now have ivanti_first_name and ivanti_last_name configured in User Management. The workflow sync queries all configured Ivanti identities and fetches workflows for each. The GET endpoint filters workflows to only show those belonging to the logged-in user's Ivanti identity. Users without an Ivanti identity see all workflows (admin fallback). If no users have identities configured, falls back to IVANTI_FIRST_NAME/ IVANTI_LAST_NAME from .env for backward compatibility. Changes: - Migration adds ivanti_first_name, ivanti_last_name to users table - Users route accepts and returns the new fields - User Management UI has Ivanti Identity input fields - Workflow sync iterates all configured user identities - Workflow GET filters by logged-in user's identity
714 lines
39 KiB
JavaScript
714 lines
39 KiB
JavaScript
// ⚠️ CONVENTION: This component uses inline styles matching the dark "tactical intelligence"
|
|
// design system (DESIGN_SYSTEM.md). Colors use the --intel-* and --text-* palette.
|
|
//
|
|
// ⚠️ CONVENTION: This file is INCOMPLETE — the exported functional component (UserManagement)
|
|
// was removed during the style refactor. Only style constants remain. The file must include:
|
|
// - A default-exported functional component using hooks (useState, useEffect)
|
|
// - Data fetching via fetch() with credentials: 'include' and relative API paths
|
|
// - Loading and error state handling in the rendered output
|
|
// - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.)
|
|
// - The ConfirmModal integration for delete/group-change confirmations
|
|
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';
|
|
import ConfirmModal from './ConfirmModal';
|
|
|
|
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: 'rgba(239, 68, 68, 0.25)', color: '#FCA5A5', border: '1px solid rgba(239, 68, 68, 0.4)' },
|
|
Standard_User: { backgroundColor: 'rgba(14, 165, 233, 0.25)', color: '#7DD3FC', border: '1px solid rgba(14, 165, 233, 0.4)' },
|
|
Leadership: { backgroundColor: 'rgba(168, 85, 247, 0.25)', color: '#C4B5FD', border: '1px solid rgba(168, 85, 247, 0.4)' },
|
|
Read_Only: { backgroundColor: 'rgba(148, 163, 184, 0.2)', color: '#CBD5E1', border: '1px solid rgba(148, 163, 184, 0.3)' }
|
|
};
|
|
|
|
/* ── Shared style constants ── */
|
|
const styles = {
|
|
overlay: {
|
|
position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: '1rem',
|
|
},
|
|
modal: {
|
|
background: 'linear-gradient(135deg, #1E293B 0%, #0F172A 100%)',
|
|
borderRadius: '0.75rem', border: '1.5px solid rgba(14,165,233,0.3)',
|
|
boxShadow: '0 8px 24px rgba(0,0,0,0.6), 0 0 28px rgba(14,165,233,0.08)',
|
|
maxWidth: '56rem', width: '100%', maxHeight: '90vh', overflow: 'hidden',
|
|
display: 'flex', flexDirection: 'column', color: '#F8FAFC',
|
|
},
|
|
header: {
|
|
padding: '1.5rem', borderBottom: '1px solid rgba(14,165,233,0.2)',
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
},
|
|
title: {
|
|
fontSize: '1.5rem', fontWeight: 700, color: '#F8FAFC', margin: 0,
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
},
|
|
subtitle: { color: '#94A3B8', fontSize: '0.875rem', margin: '0.25rem 0 0' },
|
|
closeBtn: {
|
|
background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer', padding: '0.5rem',
|
|
borderRadius: '0.375rem', transition: 'color 0.2s',
|
|
},
|
|
body: { padding: '1.5rem', overflowY: 'auto', flex: 1 },
|
|
addBtn: {
|
|
marginBottom: '1.5rem', padding: '0.5rem 1rem',
|
|
background: 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))',
|
|
border: '1px solid #0EA5E9', borderRadius: '0.5rem', color: '#38BDF8',
|
|
cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.5rem',
|
|
fontSize: '0.875rem', fontWeight: 500, transition: 'all 0.2s',
|
|
textShadow: '0 0 6px rgba(14,165,233,0.2)',
|
|
},
|
|
formCard: {
|
|
marginBottom: '1.5rem', padding: '1.5rem',
|
|
background: 'linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.9))',
|
|
borderRadius: '0.5rem', border: '1px solid rgba(14,165,233,0.25)',
|
|
},
|
|
formTitle: {
|
|
fontSize: '1.125rem', fontWeight: 600, color: '#0EA5E9', margin: '0 0 1rem',
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
},
|
|
label: {
|
|
display: 'block', fontSize: '0.75rem', fontWeight: 500, color: '#CBD5E1',
|
|
marginBottom: '0.375rem', textTransform: 'uppercase', letterSpacing: '0.5px',
|
|
},
|
|
inputWrap: { position: 'relative' },
|
|
inputIcon: {
|
|
position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)',
|
|
color: '#94A3B8', width: '1.125rem', height: '1.125rem', pointerEvents: 'none',
|
|
},
|
|
input: {
|
|
width: '100%', padding: '0.5rem 0.75rem 0.5rem 2.5rem',
|
|
background: 'rgba(30,41,59,0.6)', border: '1px solid rgba(14,165,233,0.25)',
|
|
borderRadius: '0.5rem', color: '#F8FAFC', fontSize: '0.875rem',
|
|
fontFamily: "'JetBrains Mono', monospace", outline: 'none', transition: 'border-color 0.2s',
|
|
boxSizing: 'border-box',
|
|
},
|
|
inputNoIcon: {
|
|
width: '100%', padding: '0.5rem 0.75rem',
|
|
background: 'rgba(30,41,59,0.6)', border: '1px solid rgba(14,165,233,0.25)',
|
|
borderRadius: '0.5rem', color: '#F8FAFC', fontSize: '0.875rem',
|
|
fontFamily: "'JetBrains Mono', monospace", outline: 'none', transition: 'border-color 0.2s',
|
|
boxSizing: 'border-box',
|
|
},
|
|
select: {
|
|
width: '100%', padding: '0.5rem 0.75rem 0.5rem 2.5rem',
|
|
background: 'rgba(30,41,59,0.6)', border: '1px solid rgba(14,165,233,0.25)',
|
|
borderRadius: '0.5rem', color: '#F8FAFC', fontSize: '0.875rem',
|
|
fontFamily: "'JetBrains Mono', monospace", outline: 'none', cursor: 'pointer',
|
|
appearance: 'none', boxSizing: 'border-box',
|
|
},
|
|
primaryBtn: {
|
|
padding: '0.5rem 1rem',
|
|
background: 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))',
|
|
border: '1px solid #0EA5E9', borderRadius: '0.5rem', color: '#38BDF8',
|
|
cursor: 'pointer', fontSize: '0.875rem', fontWeight: 500, transition: 'all 0.2s',
|
|
textShadow: '0 0 6px rgba(14,165,233,0.2)',
|
|
},
|
|
cancelBtn: {
|
|
padding: '0.5rem 1rem',
|
|
background: 'rgba(51,65,85,0.5)', border: '1px solid rgba(148,163,184,0.3)',
|
|
borderRadius: '0.5rem', color: '#CBD5E1', cursor: 'pointer',
|
|
fontSize: '0.875rem', fontWeight: 500, transition: 'all 0.2s',
|
|
},
|
|
alertError: {
|
|
marginBottom: '1rem', padding: '0.75rem',
|
|
background: 'rgba(239,68,68,0.15)', border: '1px solid rgba(239,68,68,0.3)',
|
|
borderRadius: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem',
|
|
},
|
|
alertSuccess: {
|
|
marginBottom: '1rem', padding: '0.75rem',
|
|
background: 'rgba(16,185,129,0.15)', border: '1px solid rgba(16,185,129,0.3)',
|
|
borderRadius: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem',
|
|
},
|
|
th: {
|
|
textAlign: 'left', padding: '0.75rem 1rem', fontSize: '0.75rem', fontWeight: 600,
|
|
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.5px',
|
|
borderBottom: '1px solid rgba(14,165,233,0.2)',
|
|
},
|
|
thRight: {
|
|
textAlign: 'right', padding: '0.75rem 1rem', fontSize: '0.75rem', fontWeight: 600,
|
|
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.5px',
|
|
borderBottom: '1px solid rgba(14,165,233,0.2)',
|
|
},
|
|
td: { padding: '0.75rem 1rem', borderBottom: '1px solid rgba(51,65,85,0.5)' },
|
|
tdRight: { padding: '0.75rem 1rem', borderBottom: '1px solid rgba(51,65,85,0.5)', textAlign: 'right' },
|
|
username: { fontWeight: 500, color: '#F8FAFC', fontSize: '0.875rem' },
|
|
email: { fontSize: '0.8rem', color: '#94A3B8' },
|
|
lastLogin: { fontSize: '0.8rem', color: '#94A3B8' },
|
|
badge: {
|
|
padding: '0.25rem 0.625rem', borderRadius: '0.375rem',
|
|
fontSize: '0.7rem', fontWeight: 600, display: 'inline-block',
|
|
fontFamily: "'JetBrains Mono', monospace", letterSpacing: '0.3px',
|
|
},
|
|
statusActive: {
|
|
padding: '0.2rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.7rem', fontWeight: 600,
|
|
background: 'rgba(16,185,129,0.2)', color: '#6EE7B7', border: '1px solid rgba(16,185,129,0.3)',
|
|
cursor: 'pointer', transition: 'opacity 0.2s',
|
|
},
|
|
statusInactive: {
|
|
padding: '0.2rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.7rem', fontWeight: 600,
|
|
background: 'rgba(239,68,68,0.2)', color: '#FCA5A5', border: '1px solid rgba(239,68,68,0.3)',
|
|
cursor: 'pointer', transition: 'opacity 0.2s',
|
|
},
|
|
actionBtn: {
|
|
background: 'none', border: 'none', padding: '0.375rem', borderRadius: '0.375rem',
|
|
cursor: 'pointer', color: '#94A3B8', transition: 'all 0.2s',
|
|
},
|
|
deleteBtn: {
|
|
background: 'none', border: 'none', padding: '0.375rem', borderRadius: '0.375rem',
|
|
cursor: 'pointer', color: '#EF4444', transition: 'all 0.2s',
|
|
},
|
|
};
|
|
|
|
|
|
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: '',
|
|
group: 'Read_Only',
|
|
bu_teams: '',
|
|
ivanti_first_name: '',
|
|
ivanti_last_name: ''
|
|
});
|
|
const [formError, setFormError] = useState('');
|
|
const [formSuccess, setFormSuccess] = useState('');
|
|
const [pendingConfirm, setPendingConfirm] = useState(null);
|
|
|
|
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 doSubmit = async () => {
|
|
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: '', group: 'Read_Only', bu_teams: '', ivanti_first_name: '', ivanti_last_name: '' });
|
|
setFormSuccess('');
|
|
}, 1500);
|
|
} catch (err) {
|
|
setFormError(err.message);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = (e) => {
|
|
e.preventDefault();
|
|
|
|
if (editingUser && formData.group !== editingUser.group) {
|
|
let message = `Are you sure you want to change ${editingUser.username}'s group from ${editingUser.group} to ${formData.group}?`;
|
|
if (editingUser.group === 'Admin' && formData.group !== 'Admin') {
|
|
message += ` WARNING: You are removing Admin privileges from ${editingUser.username}. They will lose full system access.`;
|
|
}
|
|
setPendingConfirm({
|
|
title: 'Change User Group',
|
|
message,
|
|
confirmText: 'Change Group',
|
|
variant: editingUser.group === 'Admin' ? 'danger' : 'warning',
|
|
onConfirm: () => {
|
|
setPendingConfirm(null);
|
|
doSubmit();
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
doSubmit();
|
|
};
|
|
|
|
const handleEdit = (user) => {
|
|
setEditingUser(user);
|
|
setFormData({
|
|
username: user.username,
|
|
email: user.email,
|
|
password: '',
|
|
group: user.group,
|
|
bu_teams: user.bu_teams || '',
|
|
ivanti_first_name: user.ivanti_first_name || '',
|
|
ivanti_last_name: user.ivanti_last_name || ''
|
|
});
|
|
setShowAddUser(true);
|
|
setFormError('');
|
|
setFormSuccess('');
|
|
};
|
|
|
|
const handleDelete = async (userId) => {
|
|
setPendingConfirm({
|
|
title: 'Delete User',
|
|
message: 'Are you sure you want to delete this user?',
|
|
confirmText: 'Delete',
|
|
onConfirm: async () => {
|
|
setPendingConfirm(null);
|
|
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 isGroupDropdownDisabled = (targetUser) => {
|
|
if (!targetUser || !currentUser) return false;
|
|
return targetUser.id === currentUser.id && currentUser.group === 'Admin';
|
|
};
|
|
|
|
return (
|
|
<div style={styles.overlay}>
|
|
<div style={styles.modal}>
|
|
{/* Header */}
|
|
<div style={styles.header}>
|
|
<div>
|
|
<h2 style={styles.title}>User Management</h2>
|
|
<p style={styles.subtitle}>Manage user accounts and permissions</p>
|
|
</div>
|
|
<button onClick={onClose} style={styles.closeBtn}
|
|
onMouseEnter={e => e.currentTarget.style.color = '#F8FAFC'}
|
|
onMouseLeave={e => e.currentTarget.style.color = '#94A3B8'}>
|
|
<X style={{ width: '1.5rem', height: '1.5rem' }} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div style={styles.body}>
|
|
{!showAddUser && (
|
|
<button
|
|
onClick={() => {
|
|
setShowAddUser(true);
|
|
setEditingUser(null);
|
|
setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '', ivanti_first_name: '', ivanti_last_name: '' });
|
|
setFormError('');
|
|
setFormSuccess('');
|
|
}}
|
|
style={styles.addBtn}
|
|
onMouseEnter={e => {
|
|
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.25), rgba(14,165,233,0.2))';
|
|
e.currentTarget.style.boxShadow = '0 0 20px rgba(14,165,233,0.25)';
|
|
}}
|
|
onMouseLeave={e => {
|
|
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))';
|
|
e.currentTarget.style.boxShadow = 'none';
|
|
}}
|
|
>
|
|
<Plus style={{ width: '1.125rem', height: '1.125rem' }} />
|
|
Add User
|
|
</button>
|
|
)}
|
|
|
|
{/* Add / Edit Form */}
|
|
{showAddUser && (
|
|
<div style={styles.formCard}>
|
|
<h3 style={styles.formTitle}>
|
|
{editingUser ? 'Edit User' : 'Add New User'}
|
|
</h3>
|
|
|
|
{formError && (
|
|
<div style={styles.alertError}>
|
|
<AlertCircle style={{ width: '1.125rem', height: '1.125rem', color: '#FCA5A5', flexShrink: 0 }} />
|
|
<span style={{ fontSize: '0.8rem', color: '#FCA5A5' }}>{formError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{formSuccess && (
|
|
<div style={styles.alertSuccess}>
|
|
<CheckCircle style={{ width: '1.125rem', height: '1.125rem', color: '#6EE7B7', flexShrink: 0 }} />
|
|
<span style={{ fontSize: '0.8rem', color: '#6EE7B7' }}>{formSuccess}</span>
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
|
<div>
|
|
<label style={styles.label}>Username *</label>
|
|
<div style={styles.inputWrap}>
|
|
<User style={styles.inputIcon} />
|
|
<input
|
|
type="text"
|
|
required
|
|
value={formData.username}
|
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
|
style={styles.input}
|
|
placeholder="Enter username"
|
|
onFocus={e => e.target.style.borderColor = '#0EA5E9'}
|
|
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.25)'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={styles.label}>Email *</label>
|
|
<div style={styles.inputWrap}>
|
|
<Mail style={styles.inputIcon} />
|
|
<input
|
|
type="email"
|
|
required
|
|
value={formData.email}
|
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
style={styles.input}
|
|
placeholder="user@example.com"
|
|
onFocus={e => e.target.style.borderColor = '#0EA5E9'}
|
|
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.25)'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={styles.label}>
|
|
Password {editingUser ? '(leave blank to keep current)' : '*'}
|
|
</label>
|
|
<input
|
|
type="password"
|
|
required={!editingUser}
|
|
value={formData.password}
|
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
style={styles.inputNoIcon}
|
|
onFocus={e => e.target.style.borderColor = '#0EA5E9'}
|
|
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.25)'}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={styles.label}>Group *</label>
|
|
<div style={styles.inputWrap}>
|
|
<Shield style={styles.inputIcon} />
|
|
<select
|
|
value={formData.group}
|
|
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
|
|
disabled={isGroupDropdownDisabled(editingUser)}
|
|
style={{
|
|
...styles.select,
|
|
opacity: isGroupDropdownDisabled(editingUser) ? 0.5 : 1,
|
|
cursor: isGroupDropdownDisabled(editingUser) ? 'not-allowed' : 'pointer',
|
|
}}
|
|
title={isGroupDropdownDisabled(editingUser) ? 'Cannot change your own Admin group' : ''}
|
|
>
|
|
{VALID_GROUPS.map((g) => (
|
|
<option key={g} value={g} style={{ background: '#1E293B', color: '#F8FAFC' }}>
|
|
{GROUP_LABELS[g]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{isGroupDropdownDisabled(editingUser) && (
|
|
<p style={{ fontSize: '0.7rem', color: '#F59E0B', marginTop: '0.375rem' }}>
|
|
You cannot change your own Admin group.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* BU Teams assignment */}
|
|
<div style={{ marginTop: '1rem' }}>
|
|
<label style={styles.label}>BU Teams</label>
|
|
<div style={{
|
|
display: 'flex', flexWrap: 'wrap', gap: '0.5rem',
|
|
padding: '0.75rem',
|
|
background: 'rgba(30,41,59,0.6)',
|
|
border: '1px solid rgba(14,165,233,0.25)',
|
|
borderRadius: '0.5rem',
|
|
}}>
|
|
{['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'].map(team => {
|
|
const currentTeams = formData.bu_teams ? formData.bu_teams.split(',').filter(Boolean) : [];
|
|
const isChecked = currentTeams.includes(team);
|
|
return (
|
|
<label key={team} style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
|
cursor: 'pointer', fontSize: '0.8rem', fontFamily: 'monospace',
|
|
color: isChecked ? '#38BDF8' : '#94A3B8',
|
|
padding: '0.25rem 0.5rem', borderRadius: '0.25rem',
|
|
background: isChecked ? 'rgba(14,165,233,0.1)' : 'transparent',
|
|
border: isChecked ? '1px solid rgba(14,165,233,0.3)' : '1px solid transparent',
|
|
transition: 'all 0.15s',
|
|
}}>
|
|
<input
|
|
type="checkbox"
|
|
checked={isChecked}
|
|
onChange={() => {
|
|
const updated = isChecked
|
|
? currentTeams.filter(t => t !== team)
|
|
: [...currentTeams, team];
|
|
setFormData({ ...formData, bu_teams: updated.join(',') });
|
|
}}
|
|
style={{ accentColor: '#0EA5E9' }}
|
|
/>
|
|
{team}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<p style={{ fontSize: '0.65rem', color: '#64748B', marginTop: '0.375rem' }}>
|
|
Determines which BU data the user sees on Reporting and Compliance pages.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Ivanti Identity */}
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '0.75rem', color: '#94A3B8', marginBottom: '0.375rem', fontWeight: '600' }}>
|
|
Ivanti Identity
|
|
</label>
|
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
<input
|
|
type="text"
|
|
style={{ ...styles.input, flex: 1 }}
|
|
value={formData.ivanti_first_name || ''}
|
|
onChange={e => setFormData({ ...formData, ivanti_first_name: e.target.value })}
|
|
placeholder="First name in Ivanti"
|
|
/>
|
|
<input
|
|
type="text"
|
|
style={{ ...styles.input, flex: 1 }}
|
|
value={formData.ivanti_last_name || ''}
|
|
onChange={e => setFormData({ ...formData, ivanti_last_name: e.target.value })}
|
|
placeholder="Last name in Ivanti"
|
|
/>
|
|
</div>
|
|
<p style={{ fontSize: '0.65rem', color: '#64748B', marginTop: '0.375rem' }}>
|
|
Used to filter FP workflows — must match the name in Ivanti exactly.
|
|
</p>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '0.75rem', paddingTop: '1rem' }}>
|
|
<button type="submit" style={styles.primaryBtn}
|
|
onMouseEnter={e => {
|
|
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.25), rgba(14,165,233,0.2))';
|
|
e.currentTarget.style.boxShadow = '0 0 20px rgba(14,165,233,0.25)';
|
|
}}
|
|
onMouseLeave={e => {
|
|
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))';
|
|
e.currentTarget.style.boxShadow = 'none';
|
|
}}>
|
|
{editingUser ? 'Update User' : 'Create User'}
|
|
</button>
|
|
<button type="button" style={styles.cancelBtn}
|
|
onClick={() => { setShowAddUser(false); setEditingUser(null); }}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(51,65,85,0.8)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'rgba(51,65,85,0.5)'}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* User Table */}
|
|
{loading ? (
|
|
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
|
<Loader style={{ width: '2rem', height: '2rem', color: '#0EA5E9', margin: '0 auto', animation: 'spin 1s linear infinite' }} />
|
|
<p style={{ color: '#94A3B8', marginTop: '0.5rem', fontSize: '0.875rem' }}>Loading users...</p>
|
|
</div>
|
|
) : error ? (
|
|
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
|
<AlertCircle style={{ width: '2rem', height: '2rem', color: '#EF4444', margin: '0 auto' }} />
|
|
<p style={{ color: '#FCA5A5', marginTop: '0.5rem', fontSize: '0.875rem' }}>{error}</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr>
|
|
<th style={styles.th}>User</th>
|
|
<th style={styles.th}>Group</th>
|
|
<th style={styles.th}>Teams</th>
|
|
<th style={styles.th}>Status</th>
|
|
<th style={styles.th}>Last Login</th>
|
|
<th style={styles.thRight}>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((user) => (
|
|
<tr key={user.id}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
|
<td style={styles.td}>
|
|
<div>
|
|
<p style={styles.username}>{user.username}</p>
|
|
<p style={styles.email}>{user.email}</p>
|
|
</div>
|
|
</td>
|
|
<td style={styles.td}>
|
|
<span style={{
|
|
...styles.badge,
|
|
...(GROUP_BADGE_STYLES[user.group] || GROUP_BADGE_STYLES.Read_Only),
|
|
}}>
|
|
{user.group ? user.group.replace('_', ' ') : 'Read Only'}
|
|
</span>
|
|
</td>
|
|
<td style={styles.td}>
|
|
{(user.teams && user.teams.length > 0) ? (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
|
{user.teams.map(t => (
|
|
<span key={t} style={{
|
|
fontSize: '0.65rem', fontFamily: 'monospace',
|
|
padding: '0.1rem 0.35rem', borderRadius: '0.2rem',
|
|
background: 'rgba(14,165,233,0.1)',
|
|
border: '1px solid rgba(14,165,233,0.25)',
|
|
color: '#7DD3FC',
|
|
}}>{t}</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span style={{ fontSize: '0.7rem', color: '#F59E0B', fontStyle: 'italic' }}>
|
|
⚠ No teams
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td style={styles.td}>
|
|
<button
|
|
onClick={() => handleToggleActive(user)}
|
|
disabled={user.id === currentUser.id}
|
|
style={{
|
|
...(user.is_active ? styles.statusActive : styles.statusInactive),
|
|
opacity: user.id === currentUser.id ? 0.5 : 1,
|
|
cursor: user.id === currentUser.id ? 'not-allowed' : 'pointer',
|
|
}}
|
|
>
|
|
{user.is_active ? 'Active' : 'Inactive'}
|
|
</button>
|
|
</td>
|
|
<td style={styles.td}>
|
|
<span style={styles.lastLogin}>
|
|
{user.last_login
|
|
? new Date(user.last_login).toLocaleString()
|
|
: 'Never'}
|
|
</span>
|
|
</td>
|
|
<td style={styles.tdRight}>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.25rem' }}>
|
|
<button
|
|
onClick={() => handleEdit(user)}
|
|
style={styles.actionBtn}
|
|
title="Edit user"
|
|
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; }}
|
|
onMouseLeave={e => { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.background = 'none'; }}
|
|
>
|
|
<Edit2 style={{ width: '1rem', height: '1rem' }} />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(user.id)}
|
|
disabled={user.id === currentUser.id}
|
|
style={{
|
|
...styles.deleteBtn,
|
|
opacity: user.id === currentUser.id ? 0.3 : 1,
|
|
cursor: user.id === currentUser.id ? 'not-allowed' : 'pointer',
|
|
}}
|
|
title="Delete user"
|
|
onMouseEnter={e => { if (user.id !== currentUser.id) { e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; } }}
|
|
onMouseLeave={e => { e.currentTarget.style.background = 'none'; }}
|
|
>
|
|
<Trash2 style={{ width: '1rem', height: '1rem' }} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Confirmation Modal */}
|
|
<ConfirmModal
|
|
open={!!pendingConfirm}
|
|
title={pendingConfirm?.title}
|
|
message={pendingConfirm?.message}
|
|
confirmText={pendingConfirm?.confirmText}
|
|
variant={pendingConfirm?.variant || 'danger'}
|
|
onConfirm={pendingConfirm?.onConfirm}
|
|
onCancel={() => setPendingConfirm(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
} |