Files
cve-dashboard/frontend/src/components/UserProfilePanel.js

755 lines
26 KiB
JavaScript

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>
);
}