Add user profile panel with self-service password change and dark theme UserMenu
This commit is contained in:
@@ -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);
|
||||
153
frontend/src/__tests__/UserProfilePanel.property.test.js
Normal file
153
frontend/src/__tests__/UserProfilePanel.property.test.js
Normal 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);
|
||||
@@ -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 1–7 (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 1–7 (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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
754
frontend/src/components/UserProfilePanel.js
Normal file
754
frontend/src/components/UserProfilePanel.js
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user