import React, { useState, useEffect, useRef, useCallback } from 'react'; import { X, User, Mail, Shield, Calendar, Clock, Loader, AlertCircle, RefreshCw, Lock, Eye, EyeOff, CheckCircle } from 'lucide-react'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; // ============================================ // INLINE STYLES — Dark theme matching DESIGN_SYSTEM.md // ============================================ const STYLES = { overlay: { position: 'fixed', inset: 0, background: 'rgba(10, 14, 39, 0.97)', backdropFilter: 'blur(12px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: '1rem', }, panel: { background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)', border: '1.5px solid rgba(14, 165, 233, 0.3)', borderRadius: '0.5rem', boxShadow: '0 20px 60px rgba(0, 0, 0, 0.6), 0 10px 30px rgba(14, 165, 233, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)', width: '100%', maxWidth: '480px', maxHeight: '90vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative', }, header: { padding: '1.25rem 1.5rem', borderBottom: '1px solid rgba(14, 165, 233, 0.2)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0, }, headerTitle: { color: '#F8FAFC', fontSize: '1.25rem', fontWeight: '600', margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem', }, headerIcon: { color: '#0EA5E9', }, closeButton: { background: 'transparent', border: 'none', color: '#94A3B8', cursor: 'pointer', padding: '0.25rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.2s', }, body: { padding: '1.5rem', overflowY: 'auto', flex: 1, }, // Profile info section profileSection: { display: 'flex', flexDirection: 'column', gap: '0.75rem', }, fieldRow: { display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.625rem 0.75rem', background: 'rgba(15, 23, 42, 0.6)', border: '1px solid rgba(14, 165, 233, 0.15)', borderRadius: '0.375rem', }, fieldIcon: { color: '#0EA5E9', flexShrink: 0, }, fieldContent: { display: 'flex', flexDirection: 'column', minWidth: 0, }, fieldLabel: { color: '#94A3B8', fontSize: '0.7rem', fontWeight: '500', textTransform: 'uppercase', letterSpacing: '0.05em', }, fieldValue: { color: '#F8FAFC', fontSize: '0.875rem', fontWeight: '400', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, // Loading state loadingContainer: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '3rem 1rem', gap: '0.75rem', }, loadingText: { color: '#94A3B8', fontSize: '0.875rem', }, // Error state errorContainer: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem 1rem', gap: '0.75rem', }, errorText: { color: '#FCA5A5', fontSize: '0.875rem', textAlign: 'center', }, retryButton: { display: 'inline-flex', alignItems: 'center', gap: '0.375rem', padding: '0.5rem 1rem', background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.1) 100%)', border: '1px solid #0EA5E9', borderRadius: '0.375rem', color: '#38BDF8', fontSize: '0.8rem', fontWeight: '600', cursor: 'pointer', transition: 'all 0.2s', }, // Separator between profile info and password form separator: { height: '1px', background: 'linear-gradient(90deg, transparent, rgba(14, 165, 233, 0.3), transparent)', margin: '1.5rem 0', border: 'none', }, // Password change section passwordSection: { display: 'flex', flexDirection: 'column', gap: '0.75rem', }, passwordHeading: { color: '#F8FAFC', fontSize: '1rem', fontWeight: '600', margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem', }, passwordHeadingIcon: { color: '#0EA5E9', }, formGroup: { display: 'flex', flexDirection: 'column', gap: '0.25rem', }, inputLabel: { color: '#94A3B8', fontSize: '0.75rem', fontWeight: '500', textTransform: 'uppercase', letterSpacing: '0.05em', }, inputWrapper: { position: 'relative', display: 'flex', alignItems: 'center', }, input: { width: '100%', padding: '0.625rem 0.75rem', paddingRight: '2.5rem', background: 'rgba(15, 23, 42, 0.6)', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.375rem', color: '#F8FAFC', fontSize: '0.875rem', fontFamily: "'JetBrains Mono', monospace", outline: 'none', transition: 'border-color 0.2s, box-shadow 0.2s', boxSizing: 'border-box', }, inputError: { borderColor: 'rgba(239, 68, 68, 0.5)', }, visibilityToggle: { position: 'absolute', right: '0.5rem', background: 'transparent', border: 'none', color: '#94A3B8', cursor: 'pointer', padding: '0.25rem', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.2s', }, validationError: { color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.125rem', }, submitButton: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', padding: '0.625rem 1.25rem', background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.1) 100%)', border: '1px solid #0EA5E9', borderRadius: '0.375rem', color: '#38BDF8', fontSize: '0.875rem', fontWeight: '600', cursor: 'pointer', transition: 'all 0.2s', marginTop: '0.5rem', width: '100%', }, submitButtonDisabled: { opacity: 0.5, cursor: 'not-allowed', }, changeError: { display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.625rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem', color: '#FCA5A5', fontSize: '0.8rem', }, changeSuccess: { display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.625rem 0.75rem', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '0.375rem', color: '#6EE7B7', fontSize: '0.8rem', }, // Group badge groupBadge: (group) => { const colors = { Admin: { bg: 'rgba(239, 68, 68, 0.2)', border: 'rgba(239, 68, 68, 0.5)', text: '#FCA5A5' }, Standard_User: { bg: 'rgba(14, 165, 233, 0.2)', border: 'rgba(14, 165, 233, 0.5)', text: '#7DD3FC' }, Leadership: { bg: 'rgba(168, 85, 247, 0.2)', border: 'rgba(168, 85, 247, 0.5)', text: '#D8B4FE' }, Read_Only: { bg: 'rgba(148, 163, 184, 0.2)', border: 'rgba(148, 163, 184, 0.5)', text: '#CBD5E1' }, }; const c = colors[group] || colors.Read_Only; return { display: 'inline-block', padding: '0.125rem 0.5rem', background: c.bg, border: `1px solid ${c.border}`, borderRadius: '0.25rem', color: c.text, fontSize: '0.8rem', fontWeight: '500', }; }, }; /** * Format a date string into a user-friendly format. * e.g. "Jan 15, 2026 at 10:30 AM" */ function formatDate(dateStr) { if (!dateStr) return 'Never'; try { const date = new Date(dateStr); if (isNaN(date.getTime())) return 'Unknown'; return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }) + ' at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, }); } catch { return 'Unknown'; } } function formatGroupName(group) { if (!group) return ''; return group.replace(/_/g, ' '); } export default function UserProfilePanel({ isOpen, onClose }) { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Password change form state const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [changeLoading, setChangeLoading] = useState(false); const [changeError, setChangeError] = useState(null); const [changeSuccess, setChangeSuccess] = useState(null); // Password visibility toggles const [showCurrentPassword, setShowCurrentPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const panelRef = useRef(null); const fetchProfile = useCallback(async () => { setLoading(true); setError(null); try { const response = await fetch(`${API_BASE}/auth/profile`, { credentials: 'include', }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `Failed to fetch profile (${response.status})`); } const data = await response.json(); setProfile(data); } catch (err) { setError(err.message); } finally { setLoading(false); } }, []); /** * Client-side validation for the password change form. * Returns an object with field-specific error messages, or null if valid. */ function validatePasswordForm() { const errors = {}; if (newPassword.length > 0 && newPassword.length < 8) { errors.newPassword = 'Password must be at least 8 characters'; } if (confirmPassword.length > 0 && newPassword !== confirmPassword) { errors.confirmPassword = 'Passwords do not match'; } return Object.keys(errors).length > 0 ? errors : null; } const validationErrors = validatePasswordForm(); /** * Returns true if the form can be submitted: * all fields filled, no validation errors, not currently loading. */ function canSubmitPasswordForm() { return ( currentPassword.length > 0 && newPassword.length >= 8 && confirmPassword.length > 0 && newPassword === confirmPassword && !changeLoading ); } async function handlePasswordChange(e) { e.preventDefault(); // Final client-side validation guard if (!canSubmitPasswordForm()) return; setChangeLoading(true); setChangeError(null); setChangeSuccess(null); try { const response = await fetch(`${API_BASE}/auth/change-password`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, newPassword }), }); const data = await response.json().catch(() => ({})); if (!response.ok) { if (response.status === 401) { throw new Error(data.error || 'Current password is incorrect'); } else if (response.status === 429) { throw new Error(data.error || 'Too many attempts. Please try again later.'); } else if (response.status === 400) { throw new Error(data.error || 'Validation error'); } else { throw new Error(data.error || 'An error occurred. Please try again.'); } } // Success — clear form and show message setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); setShowCurrentPassword(false); setShowNewPassword(false); setShowConfirmPassword(false); setChangeSuccess(data.message || 'Password changed successfully'); } catch (err) { setChangeError(err.message); } finally { setChangeLoading(false); } } // Fetch profile when modal opens useEffect(() => { if (isOpen) { fetchProfile(); } else { // Reset state when closed setProfile(null); setError(null); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); setChangeLoading(false); setChangeError(null); setChangeSuccess(null); setShowCurrentPassword(false); setShowNewPassword(false); setShowConfirmPassword(false); } }, [isOpen, fetchProfile]); // Click-outside-to-close useEffect(() => { if (!isOpen) return; function handleClickOutside(event) { if (panelRef.current && !panelRef.current.contains(event.target)) { onClose(); } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [isOpen, onClose]); // Escape key to close useEffect(() => { if (!isOpen) return; function handleKeyDown(event) { if (event.key === 'Escape') { onClose(); } } document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, onClose]); if (!isOpen) return null; return (
{/* Header */}

My Profile

{/* Body */}
{/* Loading state */} {loading && (
Loading profile...
)} {/* Error state */} {!loading && error && (
{error}
)} {/* Profile info section */} {!loading && !error && profile && (
{/* Username */}
Username {profile.username}
{/* Email */}
Email {profile.email}
{/* Group */}
Group {formatGroupName(profile.group)}
{/* Created At */}
Account Created {formatDate(profile.created_at)}
{/* Last Login */}
Last Login {formatDate(profile.last_login)}
)} {/* Password change section — shown when profile is loaded */} {!loading && !error && profile && ( <>

Change Password

{/* Success message */} {changeSuccess && (
{changeSuccess}
)} {/* API error message */} {changeError && (
{changeError}
)}
{/* Current Password */}
{ setCurrentPassword(e.target.value); setChangeError(null); }} style={STYLES.input} onFocus={(e) => { e.currentTarget.style.borderColor = '#0EA5E9'; e.currentTarget.style.boxShadow = '0 0 0 2px rgba(14, 165, 233, 0.15)'; }} onBlur={(e) => { e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; e.currentTarget.style.boxShadow = 'none'; }} placeholder="Enter current password" autoComplete="current-password" />
{/* New Password */}
{ setNewPassword(e.target.value); setChangeError(null); }} style={{ ...STYLES.input, ...(validationErrors?.newPassword ? STYLES.inputError : {}), }} onFocus={(e) => { e.currentTarget.style.borderColor = validationErrors?.newPassword ? 'rgba(239, 68, 68, 0.5)' : '#0EA5E9'; e.currentTarget.style.boxShadow = '0 0 0 2px rgba(14, 165, 233, 0.15)'; }} onBlur={(e) => { e.currentTarget.style.borderColor = validationErrors?.newPassword ? 'rgba(239, 68, 68, 0.5)' : 'rgba(14, 165, 233, 0.25)'; e.currentTarget.style.boxShadow = 'none'; }} placeholder="Minimum 8 characters" autoComplete="new-password" />
{validationErrors?.newPassword && ( {validationErrors.newPassword} )}
{/* Confirm New Password */}
{ setConfirmPassword(e.target.value); setChangeError(null); }} style={{ ...STYLES.input, ...(validationErrors?.confirmPassword ? STYLES.inputError : {}), }} onFocus={(e) => { e.currentTarget.style.borderColor = validationErrors?.confirmPassword ? 'rgba(239, 68, 68, 0.5)' : '#0EA5E9'; e.currentTarget.style.boxShadow = '0 0 0 2px rgba(14, 165, 233, 0.15)'; }} onBlur={(e) => { e.currentTarget.style.borderColor = validationErrors?.confirmPassword ? 'rgba(239, 68, 68, 0.5)' : 'rgba(14, 165, 233, 0.25)'; e.currentTarget.style.boxShadow = 'none'; }} placeholder="Re-enter new password" autoComplete="new-password" />
{validationErrors?.confirmPassword && ( {validationErrors.confirmPassword} )}
{/* Submit button */}
)}
); }