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

@@ -0,0 +1,101 @@
/**
* Property-Based Test: Mismatched password confirmation is rejected client-side
*
* Feature: user-profile, Property 5: Mismatched password confirmation is rejected client-side
* **Validates: Requirements 2.4**
*
* For any two distinct strings used as newPassword and confirmPassword in the
* Password_Change_Form, the form displays a validation error and does not
* submit a request to the Auth_API.
*/
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import fc from 'fast-check';
import UserProfilePanel from '../components/UserProfilePanel';
// Mock profile returned by the API so the form renders
const MOCK_PROFILE = {
id: 1,
username: 'testuser',
email: 'test@example.com',
group: 'Standard_User',
created_at: '2026-01-15T10:30:00Z',
last_login: '2026-07-20T14:22:00Z',
};
beforeEach(() => {
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
});
afterEach(() => {
jest.restoreAllMocks();
});
it('Property 5: Mismatched password confirmation is rejected client-side', async () => {
// Arbitrary: generate a newPassword of at least 8 characters and a distinct confirmPassword
// (also non-empty so the validation message renders).
const mismatchedPairArbitrary = fc
.tuple(
fc.string({ minLength: 8, maxLength: 40 }).filter(s => s.trim().length >= 8),
fc.string({ minLength: 1, maxLength: 40 })
)
.filter(([newPw, confirmPw]) => newPw !== confirmPw);
await fc.assert(
fc.asyncProperty(mismatchedPairArbitrary, async ([newPassword, confirmPassword]) => {
// Mock fetch: first call returns the profile, subsequent calls are tracked
const fetchMock = jest.fn().mockImplementation((url) => {
if (typeof url === 'string' && url.includes('/auth/profile')) {
return Promise.resolve({
ok: true,
json: async () => ({ ...MOCK_PROFILE }),
});
}
// Any other call (e.g. change-password) — should NOT happen
return Promise.resolve({
ok: true,
json: async () => ({ message: 'Password changed successfully' }),
});
});
global.fetch = fetchMock;
const onClose = jest.fn();
const { container, unmount, getByPlaceholderText } = render(
<UserProfilePanel isOpen={true} onClose={onClose} />
);
try {
// Wait for the profile to load
await waitFor(() => {
expect(container.textContent).toContain(MOCK_PROFILE.username);
}, { timeout: 3000 });
// Fill in the form fields
const currentPwInput = getByPlaceholderText('Enter current password');
const newPwInput = getByPlaceholderText('Minimum 8 characters');
const confirmPwInput = getByPlaceholderText('Re-enter new password');
fireEvent.change(currentPwInput, { target: { value: 'currentpass1' } });
fireEvent.change(newPwInput, { target: { value: newPassword } });
fireEvent.change(confirmPwInput, { target: { value: confirmPassword } });
// Assert the "Passwords do not match" validation error is displayed
expect(container.textContent).toContain('Passwords do not match');
// Assert the submit button is disabled
const submitButton = container.querySelector('button[type="submit"]');
expect(submitButton).not.toBeNull();
expect(submitButton.disabled).toBe(true);
// Assert that no call was made to the change-password endpoint
const changePasswordCalls = fetchMock.mock.calls.filter(
([url]) => typeof url === 'string' && url.includes('/auth/change-password')
);
expect(changePasswordCalls).toHaveLength(0);
} finally {
unmount();
}
}),
{ numRuns: 100 }
);
}, 120000);

View File

@@ -0,0 +1,153 @@
/**
* Property-Based Test: Profile panel displays all required fields
*
* Feature: user-profile, Property 1: Profile panel displays all required fields
* **Validates: Requirements 1.2**
*
* For any valid profile object with arbitrary username, email, group, created_at,
* and last_login values, rendering UserProfilePanel displays all five values
* in the output.
*/
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import fc from 'fast-check';
import UserProfilePanel from '../components/UserProfilePanel';
// Replicate the component's formatting logic so we know what to expect in the DOM
function formatGroupName(group) {
if (!group) return '';
return group.replace(/_/g, ' ');
}
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';
}
}
// Generate ISO date strings from integer timestamps to avoid invalid Date issues
const MIN_TS = new Date('2020-01-01T00:00:00Z').getTime();
const MAX_TS = new Date('2030-12-31T23:59:59Z').getTime();
const isoDateArbitrary = fc
.integer({ min: MIN_TS, max: MAX_TS })
.map(ts => new Date(ts).toISOString());
// Arbitrary that generates valid profile objects.
// Use minLength >= 3 for username to avoid single-character strings that
// match substrings in other UI text (e.g., "d" appearing in "Password").
// Use a custom email generator with a longer local part for the same reason.
const profileArbitrary = fc.record({
id: fc.integer({ min: 1, max: 100000 }),
username: fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_]{2,19}$/),
email: fc.tuple(
fc.stringMatching(/^[a-z]{4,10}$/),
fc.stringMatching(/^[a-z]{3,8}$/),
fc.constantFrom('com', 'org', 'net', 'io')
).map(([local, domain, tld]) => `${local}@${domain}.${tld}`),
group: fc.constantFrom('Admin', 'Standard_User', 'Leadership', 'Read_Only'),
created_at: isoDateArbitrary,
last_login: isoDateArbitrary,
});
/**
* Helper: find all fieldValue spans in the rendered component.
* The component renders each profile field in a fieldRow div containing
* a fieldLabel span and a fieldValue span. We query by the known label
* text to locate the corresponding value span.
*/
function getFieldValueByLabel(container, labelText) {
// Each field row has structure:
// <div style={fieldRow}>
// <svg ... />
// <div style={fieldContent}>
// <span style={fieldLabel}>LABEL</span>
// <span style={fieldValue}>VALUE</span>
// </div>
// </div>
const labels = container.querySelectorAll('span');
for (const label of labels) {
if (label.textContent.trim().toLowerCase() === labelText.toLowerCase()) {
// The value span is the next sibling of the label span
const valueSibling = label.nextElementSibling;
if (valueSibling) {
return valueSibling.textContent;
}
}
}
return null;
}
beforeEach(() => {
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
});
afterEach(() => {
jest.restoreAllMocks();
});
it('Property 1: Profile panel displays all required fields for any valid profile', async () => {
await fc.assert(
fc.asyncProperty(profileArbitrary, async (profile) => {
// Mock fetch to return the generated profile
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ ...profile }),
});
const onClose = jest.fn();
const { container, unmount } = render(
<UserProfilePanel isOpen={true} onClose={onClose} />
);
try {
// Wait for the profile data to be fetched and rendered.
// Check for the username label to confirm the profile section loaded.
await waitFor(() => {
expect(getFieldValueByLabel(container, 'Username')).not.toBeNull();
}, { timeout: 3000 });
// Assert all five field values appear in their respective field rows
// 1. Username — rendered directly in the Username field value span
const usernameValue = getFieldValueByLabel(container, 'Username');
expect(usernameValue).toBe(profile.username);
// 2. Email — rendered directly in the Email field value span
const emailValue = getFieldValueByLabel(container, 'Email');
expect(emailValue).toBe(profile.email);
// 3. Group — rendered through formatGroupName in the Group field value span
const groupValue = getFieldValueByLabel(container, 'Group');
const expectedGroup = formatGroupName(profile.group);
expect(groupValue).toContain(expectedGroup);
// 4. Created at — rendered through formatDate in the Account Created field value span
const createdAtValue = getFieldValueByLabel(container, 'Account Created');
const expectedCreatedAt = formatDate(profile.created_at);
expect(createdAtValue).toBe(expectedCreatedAt);
// 5. Last login — rendered through formatDate in the Last Login field value span
const lastLoginValue = getFieldValueByLabel(container, 'Last Login');
const expectedLastLogin = formatDate(profile.last_login);
expect(lastLoginValue).toBe(expectedLastLogin);
} finally {
// Clean up to avoid leaking state between iterations
unmount();
}
}),
{ numRuns: 100 }
);
}, 120000);

View File

@@ -0,0 +1,97 @@
/**
* Property-Based Test: Short passwords are rejected client-side
*
* Feature: user-profile, Property 6 (client-side): Short passwords are rejected
* **Validates: Requirements 2.5**
*
* For any string of length 17 (non-empty so the validation message renders —
* the component checks `newPassword.length > 0 && newPassword.length < 8`),
* the form displays a minimum-length validation error and does not submit a
* request to the Auth_API.
*/
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import fc from 'fast-check';
import UserProfilePanel from '../components/UserProfilePanel';
// Mock profile returned by the API so the form renders
const MOCK_PROFILE = {
id: 1,
username: 'testuser',
email: 'test@example.com',
group: 'Standard_User',
created_at: '2026-01-15T10:30:00Z',
last_login: '2026-07-20T14:22:00Z',
};
beforeEach(() => {
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
});
afterEach(() => {
jest.restoreAllMocks();
});
it('Property 6 (client-side): Short passwords are rejected', async () => {
// Generate strings of length 17 (non-empty so the validation triggers)
const shortPasswordArbitrary = fc.string({ minLength: 1, maxLength: 7 });
await fc.assert(
fc.asyncProperty(shortPasswordArbitrary, async (shortPassword) => {
// Mock fetch: first call returns the profile, subsequent calls are tracked
const fetchMock = jest.fn().mockImplementation((url) => {
if (typeof url === 'string' && url.includes('/auth/profile')) {
return Promise.resolve({
ok: true,
json: async () => ({ ...MOCK_PROFILE }),
});
}
// Any other call (e.g. change-password) — should NOT happen
return Promise.resolve({
ok: true,
json: async () => ({ message: 'Password changed successfully' }),
});
});
global.fetch = fetchMock;
const onClose = jest.fn();
const { container, unmount, getByPlaceholderText } = render(
<UserProfilePanel isOpen={true} onClose={onClose} />
);
try {
// Wait for the profile to load
await waitFor(() => {
expect(container.textContent).toContain(MOCK_PROFILE.username);
}, { timeout: 3000 });
// Fill in the form fields:
// current password, the short new password, and a matching confirm password
const currentPwInput = getByPlaceholderText('Enter current password');
const newPwInput = getByPlaceholderText('Minimum 8 characters');
const confirmPwInput = getByPlaceholderText('Re-enter new password');
fireEvent.change(currentPwInput, { target: { value: 'currentpass1' } });
fireEvent.change(newPwInput, { target: { value: shortPassword } });
fireEvent.change(confirmPwInput, { target: { value: shortPassword } });
// Assert the "Password must be at least 8 characters" validation error is displayed
expect(container.textContent).toContain('Password must be at least 8 characters');
// Assert the submit button is disabled
const submitButton = container.querySelector('button[type="submit"]');
expect(submitButton).not.toBeNull();
expect(submitButton.disabled).toBe(true);
// Assert that no call was made to the change-password endpoint
const changePasswordCalls = fetchMock.mock.calls.filter(
([url]) => typeof url === 'string' && url.includes('/auth/change-password')
);
expect(changePasswordCalls).toHaveLength(0);
} finally {
unmount();
}
}),
{ numRuns: 100 }
);
}, 120000);

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