Add user profile panel with self-service password change and dark theme UserMenu

This commit is contained in:
root
2026-04-24 17:29:06 +00:00
parent 53439b2af8
commit 8bf8dc55dd
14 changed files with 2244 additions and 34 deletions

View File

@@ -1,10 +1,162 @@
import React, { useState, useRef, useEffect } from 'react';
import { User, LogOut, ChevronDown, Shield, Clock } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import UserProfilePanel from './UserProfilePanel';
// ============================================
// INLINE STYLES — Dark theme matching DESIGN_SYSTEM.md
// ============================================
const STYLES = {
container: {
position: 'relative',
},
menuButton: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
borderRadius: '0.5rem',
background: 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'background 0.2s',
},
menuButtonHover: {
background: 'rgba(14, 165, 233, 0.1)',
},
avatar: {
width: '2rem',
height: '2rem',
backgroundColor: '#0476D9',
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
avatarIcon: {
color: '#FFFFFF',
},
userInfo: {
textAlign: 'left',
},
username: {
fontSize: '0.875rem',
fontWeight: '500',
color: '#F8FAFC',
margin: 0,
lineHeight: 1.25,
},
groupLabel: {
fontSize: '0.75rem',
color: '#E2E8F0',
margin: 0,
lineHeight: 1.25,
},
chevron: {
color: '#E2E8F0',
transition: 'transform 0.2s',
},
chevronOpen: {
transform: 'rotate(180deg)',
},
// Dropdown panel
dropdown: {
position: 'absolute',
right: 0,
marginTop: '0.5rem',
width: '16rem',
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%)',
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)',
border: '1.5px solid rgba(14, 165, 233, 0.3)',
padding: '0.5rem 0',
zIndex: 50,
},
// Dropdown header section
dropdownHeader: {
padding: '0.75rem 1rem',
borderBottom: '1px solid rgba(14, 165, 233, 0.2)',
},
dropdownHeaderName: {
fontSize: '0.875rem',
fontWeight: '500',
color: '#F8FAFC',
margin: 0,
},
dropdownHeaderEmail: {
fontSize: '0.875rem',
color: '#94A3B8',
margin: 0,
},
// Menu items
menuItem: {
width: '100%',
padding: '0.5rem 1rem',
textAlign: 'left',
fontSize: '0.875rem',
color: '#F8FAFC',
background: 'transparent',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
transition: 'background 0.15s',
},
menuItemHover: {
background: 'rgba(14, 165, 233, 0.1)',
},
// Sign out item
signOutItem: {
width: '100%',
padding: '0.5rem 1rem',
textAlign: 'left',
fontSize: '0.875rem',
color: '#F87171',
background: 'transparent',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
transition: 'background 0.15s',
},
signOutItemHover: {
background: 'rgba(239, 68, 68, 0.1)',
},
};
/**
* Returns inline style for the group badge in the dropdown header.
* Retains the existing color-coding logic per group.
*/
function getGroupBadgeStyle(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',
marginTop: '0.5rem',
padding: '0.125rem 0.5rem',
borderRadius: '0.25rem',
fontSize: '0.75rem',
fontWeight: '500',
background: c.bg,
border: `1px solid ${c.border}`,
color: c.text,
};
}
export default function UserMenu({ onManageUsers, onAuditLog }) {
const { user, logout, isAdmin } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const [buttonHovered, setButtonHovered] = useState(false);
const [hoveredItem, setHoveredItem] = useState(null);
const [showProfile, setShowProfile] = useState(false);
const menuRef = useRef(null);
// Close menu when clicking outside
@@ -19,21 +171,6 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const getGroupBadgeColor = (group) => {
switch (group) {
case 'Admin':
return 'bg-red-100 text-red-800';
case 'Standard_User':
return 'bg-blue-100 text-blue-800';
case 'Leadership':
return 'bg-purple-100 text-purple-800';
case 'Read_Only':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const formatGroupName = (group) => {
if (!group) return '';
return group.replace(/_/g, ' ');
@@ -44,6 +181,11 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
await logout();
};
const handleProfile = () => {
setIsOpen(false);
setShowProfile(true);
};
const handleManageUsers = () => {
setIsOpen(false);
if (onManageUsers) {
@@ -61,45 +203,79 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
if (!user) return null;
return (
<div className="relative" ref={menuRef}>
<div style={STYLES.container} ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors"
onMouseEnter={() => setButtonHovered(true)}
onMouseLeave={() => setButtonHovered(false)}
style={{
...STYLES.menuButton,
...(buttonHovered ? STYLES.menuButtonHover : {}),
}}
>
<div className="w-8 h-8 bg-[#0476D9] rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-white" />
<div style={STYLES.avatar}>
<User size={16} style={STYLES.avatarIcon} />
</div>
<div className="text-left hidden sm:block">
<p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-xs text-gray-500">{formatGroupName(user.group)}</p>
<div style={STYLES.userInfo} className="hidden sm:block">
<p style={STYLES.username}>{user.username}</p>
<p style={STYLES.groupLabel}>{formatGroupName(user.group)}</p>
</div>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
<ChevronDown
size={16}
style={{
...STYLES.chevron,
...(isOpen ? STYLES.chevronOpen : {}),
}}
/>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
<span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getGroupBadgeColor(user.group)}`}>
<div style={STYLES.dropdown}>
<div style={STYLES.dropdownHeader}>
<p style={STYLES.dropdownHeaderName}>{user.username}</p>
<p style={STYLES.dropdownHeaderEmail}>{user.email}</p>
<span style={getGroupBadgeStyle(user.group)}>
{formatGroupName(user.group)}
</span>
</div>
<button
onClick={handleProfile}
onMouseEnter={() => setHoveredItem('profile')}
onMouseLeave={() => setHoveredItem(null)}
style={{
...STYLES.menuItem,
...(hoveredItem === 'profile' ? STYLES.menuItemHover : {}),
}}
>
<User size={16} />
My Profile
</button>
{isAdmin() && (
<>
<button
onClick={handleManageUsers}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
onMouseEnter={() => setHoveredItem('manage')}
onMouseLeave={() => setHoveredItem(null)}
style={{
...STYLES.menuItem,
...(hoveredItem === 'manage' ? STYLES.menuItemHover : {}),
}}
>
<Shield className="w-4 h-4" />
<Shield size={16} />
Manage Users
</button>
<button
onClick={handleAuditLog}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
onMouseEnter={() => setHoveredItem('audit')}
onMouseLeave={() => setHoveredItem(null)}
style={{
...STYLES.menuItem,
...(hoveredItem === 'audit' ? STYLES.menuItemHover : {}),
}}
>
<Clock className="w-4 h-4" />
<Clock size={16} />
Audit Log
</button>
</>
@@ -107,13 +283,19 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-3"
onMouseEnter={() => setHoveredItem('signout')}
onMouseLeave={() => setHoveredItem(null)}
style={{
...STYLES.signOutItem,
...(hoveredItem === 'signout' ? STYLES.signOutItemHover : {}),
}}
>
<LogOut className="w-4 h-4" />
<LogOut size={16} />
Sign Out
</button>
</div>
)}
<UserProfilePanel isOpen={showProfile} onClose={() => setShowProfile(false)} />
</div>
);
}

View File

@@ -0,0 +1,754 @@
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 (
<div style={STYLES.overlay}>
<div ref={panelRef} style={STYLES.panel}>
{/* Header */}
<div style={STYLES.header}>
<h2 style={STYLES.headerTitle}>
<User style={STYLES.headerIcon} size={20} />
My Profile
</h2>
<button
onClick={onClose}
style={STYLES.closeButton}
onMouseEnter={(e) => { e.currentTarget.style.color = '#F8FAFC'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#94A3B8'; }}
aria-label="Close profile panel"
>
<X size={20} />
</button>
</div>
{/* Body */}
<div style={STYLES.body}>
{/* Loading state */}
{loading && (
<div style={STYLES.loadingContainer}>
<Loader size={28} color="#0EA5E9" style={{ animation: 'spin 1s linear infinite' }} />
<span style={STYLES.loadingText}>Loading profile...</span>
</div>
)}
{/* Error state */}
{!loading && error && (
<div style={STYLES.errorContainer}>
<AlertCircle size={32} color="#EF4444" />
<span style={STYLES.errorText}>{error}</span>
<button
onClick={fetchProfile}
style={STYLES.retryButton}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.25) 0%, rgba(14, 165, 233, 0.2) 100%)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.1) 100%)';
}}
>
<RefreshCw size={14} />
Retry
</button>
</div>
)}
{/* Profile info section */}
{!loading && !error && profile && (
<div style={STYLES.profileSection}>
{/* Username */}
<div style={STYLES.fieldRow}>
<User size={18} style={STYLES.fieldIcon} />
<div style={STYLES.fieldContent}>
<span style={STYLES.fieldLabel}>Username</span>
<span style={STYLES.fieldValue}>{profile.username}</span>
</div>
</div>
{/* Email */}
<div style={STYLES.fieldRow}>
<Mail size={18} style={STYLES.fieldIcon} />
<div style={STYLES.fieldContent}>
<span style={STYLES.fieldLabel}>Email</span>
<span style={STYLES.fieldValue}>{profile.email}</span>
</div>
</div>
{/* Group */}
<div style={STYLES.fieldRow}>
<Shield size={18} style={STYLES.fieldIcon} />
<div style={STYLES.fieldContent}>
<span style={STYLES.fieldLabel}>Group</span>
<span style={{ ...STYLES.fieldValue, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={STYLES.groupBadge(profile.group)}>
{formatGroupName(profile.group)}
</span>
</span>
</div>
</div>
{/* Created At */}
<div style={STYLES.fieldRow}>
<Calendar size={18} style={STYLES.fieldIcon} />
<div style={STYLES.fieldContent}>
<span style={STYLES.fieldLabel}>Account Created</span>
<span style={STYLES.fieldValue}>{formatDate(profile.created_at)}</span>
</div>
</div>
{/* Last Login */}
<div style={STYLES.fieldRow}>
<Clock size={18} style={STYLES.fieldIcon} />
<div style={STYLES.fieldContent}>
<span style={STYLES.fieldLabel}>Last Login</span>
<span style={STYLES.fieldValue}>{formatDate(profile.last_login)}</span>
</div>
</div>
</div>
)}
{/* Password change section — shown when profile is loaded */}
{!loading && !error && profile && (
<>
<hr style={STYLES.separator} />
<div style={STYLES.passwordSection}>
<h3 style={STYLES.passwordHeading}>
<Lock size={18} style={STYLES.passwordHeadingIcon} />
Change Password
</h3>
{/* Success message */}
{changeSuccess && (
<div style={STYLES.changeSuccess}>
<CheckCircle size={16} />
{changeSuccess}
</div>
)}
{/* API error message */}
{changeError && (
<div style={STYLES.changeError}>
<AlertCircle size={16} />
{changeError}
</div>
)}
<form onSubmit={handlePasswordChange} autoComplete="off">
{/* Current Password */}
<div style={{ ...STYLES.formGroup, marginBottom: '0.75rem' }}>
<label style={STYLES.inputLabel}>Current Password</label>
<div style={STYLES.inputWrapper}>
<input
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => { 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"
/>
<button
type="button"
style={STYLES.visibilityToggle}
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
onMouseEnter={(e) => { e.currentTarget.style.color = '#F8FAFC'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#94A3B8'; }}
aria-label={showCurrentPassword ? 'Hide current password' : 'Show current password'}
tabIndex={-1}
>
{showCurrentPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* New Password */}
<div style={{ ...STYLES.formGroup, marginBottom: '0.75rem' }}>
<label style={STYLES.inputLabel}>New Password</label>
<div style={STYLES.inputWrapper}>
<input
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => { 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"
/>
<button
type="button"
style={STYLES.visibilityToggle}
onClick={() => setShowNewPassword(!showNewPassword)}
onMouseEnter={(e) => { e.currentTarget.style.color = '#F8FAFC'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#94A3B8'; }}
aria-label={showNewPassword ? 'Hide new password' : 'Show new password'}
tabIndex={-1}
>
{showNewPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{validationErrors?.newPassword && (
<span style={STYLES.validationError}>{validationErrors.newPassword}</span>
)}
</div>
{/* Confirm New Password */}
<div style={{ ...STYLES.formGroup, marginBottom: '0.75rem' }}>
<label style={STYLES.inputLabel}>Confirm New Password</label>
<div style={STYLES.inputWrapper}>
<input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => { 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"
/>
<button
type="button"
style={STYLES.visibilityToggle}
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
onMouseEnter={(e) => { e.currentTarget.style.color = '#F8FAFC'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#94A3B8'; }}
aria-label={showConfirmPassword ? 'Hide confirm password' : 'Show confirm password'}
tabIndex={-1}
>
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{validationErrors?.confirmPassword && (
<span style={STYLES.validationError}>{validationErrors.confirmPassword}</span>
)}
</div>
{/* Submit button */}
<button
type="submit"
disabled={!canSubmitPasswordForm()}
style={{
...STYLES.submitButton,
...(!canSubmitPasswordForm() ? STYLES.submitButtonDisabled : {}),
}}
onMouseEnter={(e) => {
if (canSubmitPasswordForm()) {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.25) 0%, rgba(14, 165, 233, 0.2) 100%)';
e.currentTarget.style.boxShadow = '0 0 20px rgba(14, 165, 233, 0.25)';
e.currentTarget.style.transform = 'translateY(-1px)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.1) 100%)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'none';
}}
>
{changeLoading ? (
<>
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
Changing Password...
</>
) : (
<>
<Lock size={16} />
Change Password
</>
)}
</button>
</form>
</div>
</>
)}
</div>
</div>
</div>
);
}