Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useCallback } from 'react'; | |
| import { motion } from 'framer-motion'; | |
| import { HiSearch } from 'react-icons/hi'; | |
| import { HiArrowPath } from 'react-icons/hi2'; | |
| import api from '../../../services/api'; | |
| import { formatDate } from '../../../utils/helpers'; | |
| import { useAuth } from '../../../contexts/AuthContext'; | |
| import toast from 'react-hot-toast'; | |
| const ROLES = [ | |
| { value: 'citizen', label: 'Citizen', color: '#6B7280' }, | |
| { value: 'volunteer_coordinator',label: 'Vol. Coordinator', color: '#3B82F6' }, | |
| { value: 'content_manager', label: 'Content Manager', color: '#8B5CF6' }, | |
| { value: 'manager', label: 'Manager', color: '#D97706' }, | |
| { value: 'admin', label: 'Admin', color: '#EF4444' }, | |
| ]; | |
| const ROLE_STYLE = Object.fromEntries(ROLES.map(r => [r.value, r])); | |
| function RoleChangeModal({ user, onClose, onSave }) { | |
| const [newRole, setNewRole] = useState(user.role); | |
| const [saving, setSaving] = useState(false); | |
| const handle = async () => { | |
| if (newRole === user.role) { onClose(); return; } | |
| if (!window.confirm(`Change ${user.displayName || user.email}'s role to "${newRole}"?`)) return; | |
| setSaving(true); | |
| try { | |
| await api.put('/auth/role', { userId: user.uid, role: newRole }); | |
| toast.success(`Role updated to ${newRole}`); | |
| onSave(); | |
| onClose(); | |
| } catch (e) { toast.error(e.message || 'Update failed.'); } | |
| finally { setSaving(false); } | |
| }; | |
| return ( | |
| <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }} onClick={onClose}> | |
| <motion.div initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} | |
| style={{ background: 'white', borderRadius: 20, padding: '2rem', width: '100%', maxWidth: 400 }} onClick={e => e.stopPropagation()}> | |
| <h2 style={{ marginBottom: '0.5rem', fontSize: '1.1rem' }}>👤 Change Role</h2> | |
| <p style={{ color: '#9CA3AF', fontSize: '0.83rem', marginBottom: '1.25rem' }}>{user.displayName || user.email}</p> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', marginBottom: '1.25rem' }}> | |
| {ROLES.map(r => ( | |
| <label key={r.value} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.7rem 1rem', border: `2px solid ${newRole === r.value ? r.color : '#e5e7eb'}`, borderRadius: 10, cursor: 'pointer', transition: 'border 0.15s', background: newRole === r.value ? `${r.color}10` : 'white' }}> | |
| <input type="radio" name="role" value={r.value} checked={newRole === r.value} onChange={() => setNewRole(r.value)} style={{ accentColor: r.color }} /> | |
| <span style={{ fontWeight: 600, fontSize: '0.88rem', color: newRole === r.value ? r.color : '#374151' }}>{r.label}</span> | |
| </label> | |
| ))} | |
| </div> | |
| <div style={{ display: 'flex', gap: '0.75rem' }}> | |
| <button className="btn btn--ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button> | |
| <button className="btn btn--primary" style={{ flex: 1 }} onClick={handle} disabled={saving}>{saving ? 'Saving...' : 'Update Role'}</button> | |
| </div> | |
| </motion.div> | |
| </div> | |
| ); | |
| } | |
| export default function UserManagement() { | |
| const { isRole, userProfile } = useAuth(); | |
| const [users, setUsers] = useState([]); | |
| const [search, setSearch] = useState(''); | |
| const [roleFilter, setRoleFilter] = useState(''); | |
| const [loading, setLoading] = useState(true); | |
| const [editingUser, setEditingUser] = useState(null); | |
| const load = useCallback(async () => { | |
| setLoading(true); | |
| try { | |
| const data = await api.get(`/auth/users?limit=200${roleFilter ? '&role=' + roleFilter : ''}`); | |
| setUsers(data.users || []); | |
| } catch (e) { | |
| toast.error(e.message || 'Failed to load users.'); | |
| } finally { setLoading(false); } | |
| }, [roleFilter]); | |
| useEffect(() => { load(); }, [load]); | |
| const filtered = users.filter(u => | |
| !search || | |
| u.displayName?.toLowerCase().includes(search.toLowerCase()) || | |
| u.email?.toLowerCase().includes(search.toLowerCase()) | |
| ); | |
| return ( | |
| <div> | |
| {editingUser && <RoleChangeModal user={editingUser} onClose={() => setEditingUser(null)} onSave={load} />} | |
| <div className="dash-page-header"> | |
| <h1>👤 User Management</h1> | |
| <p>View all users and manage their roles and permissions.</p> | |
| </div> | |
| {/* Filters */} | |
| <div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.25rem', flexWrap: 'wrap', alignItems: 'center' }}> | |
| <div style={{ position: 'relative', flex: 1, minWidth: 200, maxWidth: 320 }}> | |
| <HiSearch style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#9CA3AF' }} /> | |
| <input className="form-input" style={{ paddingLeft: '2.25rem' }} placeholder="Search name or email..." value={search} onChange={e => setSearch(e.target.value)} /> | |
| </div> | |
| <select className="form-input" style={{ width: 'auto', minWidth: 180 }} value={roleFilter} onChange={e => setRoleFilter(e.target.value)}> | |
| <option value="">All Roles</option> | |
| {ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)} | |
| </select> | |
| <button className="btn btn--ghost btn--sm" onClick={load}><HiArrowPath /> Refresh</button> | |
| <span style={{ marginLeft: 'auto', fontSize: '0.83rem', color: '#9CA3AF' }}>{filtered.length} users</span> | |
| </div> | |
| {/* Users Table */} | |
| <div className="dash-card" style={{ padding: 0, overflow: 'hidden' }}> | |
| {loading ? ( | |
| <div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: 12 }}> | |
| {[1,2,3,4,5].map(i => <div key={i} className="dash-skeleton" style={{ height: 56 }} />)} | |
| </div> | |
| ) : filtered.length === 0 ? ( | |
| <div className="dash-empty"><div className="dash-empty__icon">👤</div><p>No users found.</p></div> | |
| ) : ( | |
| <div style={{ overflowX: 'auto' }}> | |
| <table className="dash-table"> | |
| <thead> | |
| <tr> | |
| <th>User</th> | |
| <th>Email</th> | |
| <th>Role</th> | |
| <th>Status</th> | |
| <th>Joined</th> | |
| {isRole('admin') && <th>Actions</th>} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {filtered.map((u, i) => { | |
| const roleSty = ROLE_STYLE[u.role] || ROLE_STYLE.citizen; | |
| const isSelf = u.uid === userProfile?.uid; | |
| const initials = (u.displayName || u.email || 'U').split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); | |
| return ( | |
| <motion.tr key={u.uid} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: i * 0.03 }} | |
| style={{ background: isSelf ? '#f0fdf4' : 'transparent' }}> | |
| <td> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}> | |
| {u.profileImage ? ( | |
| <img src={u.profileImage} alt={u.displayName} style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover' }} /> | |
| ) : ( | |
| <div style={{ width: 32, height: 32, borderRadius: '50%', background: 'linear-gradient(135deg, #2D6A4F, #40916C)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontSize: '0.72rem', fontWeight: 700 }}> | |
| {initials} | |
| </div> | |
| )} | |
| <div> | |
| <div style={{ fontWeight: 600, fontSize: '0.88rem', color: '#111827' }}>{u.displayName || '—'} {isSelf && <span style={{ fontSize: '0.65rem', color: '#2D6A4F' }}>(you)</span>}</div> | |
| </div> | |
| </div> | |
| </td> | |
| <td style={{ fontSize: '0.82rem', color: '#6B7280' }}>{u.email}</td> | |
| <td> | |
| <span style={{ background: `${roleSty.color}15`, color: roleSty.color, padding: '0.2rem 0.6rem', borderRadius: 999, fontSize: '0.72rem', fontWeight: 700 }}> | |
| {roleSty.label} | |
| </span> | |
| </td> | |
| <td> | |
| <span style={{ background: u.status === 'active' ? '#ecfdf5' : '#fef2f2', color: u.status === 'active' ? '#059669' : '#EF4444', padding: '0.2rem 0.6rem', borderRadius: 999, fontSize: '0.72rem', fontWeight: 600 }}> | |
| {u.status || 'active'} | |
| </span> | |
| </td> | |
| <td style={{ fontSize: '0.78rem', color: '#9CA3AF' }}>{u.createdAt ? formatDate(u.createdAt) : '—'}</td> | |
| {isRole('admin') && ( | |
| <td> | |
| {!isSelf && ( | |
| <button className="btn btn--ghost btn--sm" style={{ fontSize: '0.78rem' }} onClick={() => setEditingUser(u)}> | |
| Change Role | |
| </button> | |
| )} | |
| </td> | |
| )} | |
| </motion.tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |