Fixed critical issues with the profile page (/dashboard/profile):
1. **Form data not persisting on page refresh**:
- Added useEffect to update form values when user data loads
- Forms now properly populate after auth context loads user data
2. **Blank page on refresh**:
- Added loading and error states for better UX
- Handle case where user is not loaded yet (loading spinner)
- Handle case where user fails to load (retry button)
3. **Password change API endpoint correction**:
- Fixed: POST /api/v1/users/change-password (incorrect)
- Corrected to: PATCH /api/v1/users/me/password (matches backend)
- Updated return type to include { message: string }
The root cause was that useForm defaultValues were set once at
component mount when user was still null. The form never updated
when user data was subsequently loaded by the auth context.
Now the form properly resets with user data via useEffect, and
proper loading/error states prevent showing a blank page.
Refs: apps/frontend/app/dashboard/profile/page.tsx:68-78
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
/**
|
|
* User Profile Page
|
|
*
|
|
* Allows users to view and update their profile information
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useAuth } from '@/lib/context/auth-context';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import { updateUser, changePassword } from '@/lib/api';
|
|
|
|
// Password update schema
|
|
const passwordSchema = z
|
|
.object({
|
|
currentPassword: z.string().min(1, 'Current password is required'),
|
|
newPassword: z
|
|
.string()
|
|
.min(12, 'Password must be at least 12 characters')
|
|
.regex(
|
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
|
'Password must contain uppercase, lowercase, number, and special character'
|
|
),
|
|
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
|
})
|
|
.refine(data => data.newPassword === data.confirmPassword, {
|
|
message: "Passwords don't match",
|
|
path: ['confirmPassword'],
|
|
});
|
|
|
|
type PasswordFormData = z.infer<typeof passwordSchema>;
|
|
|
|
// Profile update schema
|
|
const profileSchema = z.object({
|
|
firstName: z.string().min(2, 'First name must be at least 2 characters'),
|
|
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
|
|
email: z.string().email('Invalid email address'),
|
|
});
|
|
|
|
type ProfileFormData = z.infer<typeof profileSchema>;
|
|
|
|
export default function ProfilePage() {
|
|
const { user, refreshUser, loading } = useAuth();
|
|
const queryClient = useQueryClient();
|
|
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
|
|
const [successMessage, setSuccessMessage] = useState('');
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
|
|
// Profile form
|
|
const profileForm = useForm<ProfileFormData>({
|
|
resolver: zodResolver(profileSchema),
|
|
defaultValues: {
|
|
firstName: user?.firstName || '',
|
|
lastName: user?.lastName || '',
|
|
email: user?.email || '',
|
|
},
|
|
});
|
|
|
|
// Password form
|
|
const passwordForm = useForm<PasswordFormData>({
|
|
resolver: zodResolver(passwordSchema),
|
|
});
|
|
|
|
// Update form values when user data loads
|
|
useEffect(() => {
|
|
if (user) {
|
|
profileForm.reset({
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
email: user.email,
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [user]);
|
|
|
|
// Update profile mutation
|
|
const updateProfileMutation = useMutation({
|
|
mutationFn: (data: ProfileFormData) => {
|
|
if (!user?.id) throw new Error('User ID not found');
|
|
return updateUser(user.id, data);
|
|
},
|
|
onSuccess: () => {
|
|
setSuccessMessage('Profile updated successfully!');
|
|
setErrorMessage('');
|
|
refreshUser();
|
|
queryClient.invalidateQueries({ queryKey: ['user'] });
|
|
setTimeout(() => setSuccessMessage(''), 3000);
|
|
},
|
|
onError: (error: any) => {
|
|
setErrorMessage(error.message || 'Failed to update profile');
|
|
setSuccessMessage('');
|
|
},
|
|
});
|
|
|
|
// Update password mutation
|
|
const updatePasswordMutation = useMutation({
|
|
mutationFn: async (data: PasswordFormData) => {
|
|
return changePassword({
|
|
currentPassword: data.currentPassword,
|
|
newPassword: data.newPassword,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
setSuccessMessage('Password updated successfully!');
|
|
setErrorMessage('');
|
|
passwordForm.reset();
|
|
setTimeout(() => setSuccessMessage(''), 3000);
|
|
},
|
|
onError: (error: any) => {
|
|
setErrorMessage(error.message || 'Failed to update password');
|
|
setSuccessMessage('');
|
|
},
|
|
});
|
|
|
|
const handleProfileSubmit = (data: ProfileFormData) => {
|
|
updateProfileMutation.mutate(data);
|
|
};
|
|
|
|
const handlePasswordSubmit = (data: PasswordFormData) => {
|
|
updatePasswordMutation.mutate(data);
|
|
};
|
|
|
|
// Show loading state while user data is being fetched
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p className="text-gray-600">Loading profile...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show error if user is not found after loading
|
|
if (!loading && !user) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="text-center">
|
|
<p className="text-red-600 mb-4">Unable to load user profile</p>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
|
|
<h1 className="text-3xl font-bold mb-2">My Profile</h1>
|
|
<p className="text-blue-100">Manage your account settings and preferences</p>
|
|
</div>
|
|
|
|
{/* Success/Error Messages */}
|
|
{successMessage && (
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
<div className="flex items-center">
|
|
<svg className="w-5 h-5 text-green-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span className="text-sm font-medium text-green-800">{successMessage}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{errorMessage && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<div className="flex items-center">
|
|
<svg className="w-5 h-5 text-red-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span className="text-sm font-medium text-red-800">{errorMessage}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* User Info Card */}
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center text-white text-2xl font-bold">
|
|
{user?.firstName?.[0]}
|
|
{user?.lastName?.[0]}
|
|
</div>
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900">
|
|
{user?.firstName} {user?.lastName}
|
|
</h2>
|
|
<p className="text-gray-600">{user?.email}</p>
|
|
<div className="flex items-center space-x-2 mt-2">
|
|
<span className="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
|
|
{user?.role}
|
|
</span>
|
|
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
|
|
Active
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="bg-white rounded-lg shadow">
|
|
<div className="border-b">
|
|
<nav className="flex space-x-8 px-6" aria-label="Tabs">
|
|
<button
|
|
onClick={() => setActiveTab('profile')}
|
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === 'profile'
|
|
? 'border-blue-500 text-blue-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
Profile Information
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('password')}
|
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === 'password'
|
|
? 'border-blue-500 text-blue-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
Change Password
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
{activeTab === 'profile' ? (
|
|
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* First Name */}
|
|
<div>
|
|
<label
|
|
htmlFor="firstName"
|
|
className="block text-sm font-medium text-gray-700 mb-2"
|
|
>
|
|
First Name
|
|
</label>
|
|
<input
|
|
{...profileForm.register('firstName')}
|
|
type="text"
|
|
id="firstName"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
{profileForm.formState.errors.firstName && (
|
|
<p className="mt-1 text-sm text-red-600">
|
|
{profileForm.formState.errors.firstName.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Last Name */}
|
|
<div>
|
|
<label
|
|
htmlFor="lastName"
|
|
className="block text-sm font-medium text-gray-700 mb-2"
|
|
>
|
|
Last Name
|
|
</label>
|
|
<input
|
|
{...profileForm.register('lastName')}
|
|
type="text"
|
|
id="lastName"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
{profileForm.formState.errors.lastName && (
|
|
<p className="mt-1 text-sm text-red-600">
|
|
{profileForm.formState.errors.lastName.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Email */}
|
|
<div>
|
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
|
Email Address
|
|
</label>
|
|
<input
|
|
{...profileForm.register('email')}
|
|
type="email"
|
|
id="email"
|
|
disabled
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-500">Email cannot be changed</p>
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={updateProfileMutation.isPending}
|
|
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{updateProfileMutation.isPending ? 'Saving...' : 'Save Changes'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6">
|
|
{/* Current Password */}
|
|
<div>
|
|
<label
|
|
htmlFor="currentPassword"
|
|
className="block text-sm font-medium text-gray-700 mb-2"
|
|
>
|
|
Current Password
|
|
</label>
|
|
<input
|
|
{...passwordForm.register('currentPassword')}
|
|
type="password"
|
|
id="currentPassword"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
{passwordForm.formState.errors.currentPassword && (
|
|
<p className="mt-1 text-sm text-red-600">
|
|
{passwordForm.formState.errors.currentPassword.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* New Password */}
|
|
<div>
|
|
<label
|
|
htmlFor="newPassword"
|
|
className="block text-sm font-medium text-gray-700 mb-2"
|
|
>
|
|
New Password
|
|
</label>
|
|
<input
|
|
{...passwordForm.register('newPassword')}
|
|
type="password"
|
|
id="newPassword"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
{passwordForm.formState.errors.newPassword && (
|
|
<p className="mt-1 text-sm text-red-600">
|
|
{passwordForm.formState.errors.newPassword.message}
|
|
</p>
|
|
)}
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
Must be at least 12 characters with uppercase, lowercase, number, and special
|
|
character
|
|
</p>
|
|
</div>
|
|
|
|
{/* Confirm Password */}
|
|
<div>
|
|
<label
|
|
htmlFor="confirmPassword"
|
|
className="block text-sm font-medium text-gray-700 mb-2"
|
|
>
|
|
Confirm New Password
|
|
</label>
|
|
<input
|
|
{...passwordForm.register('confirmPassword')}
|
|
type="password"
|
|
id="confirmPassword"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
{passwordForm.formState.errors.confirmPassword && (
|
|
<p className="mt-1 text-sm text-red-600">
|
|
{passwordForm.formState.errors.confirmPassword.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={updatePasswordMutation.isPending}
|
|
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{updatePasswordMutation.isPending ? 'Updating...' : 'Update Password'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|