xpeditis2.0/apps/frontend/app/dashboard/profile/page.tsx
David 905a56888a fix: implement password change functionality in profile page
Fix password change feature that was previously non-functional:
- Add changePassword function in frontend API (src/lib/api/users.ts)
- Update API endpoint to match backend: PATCH /api/v1/users/me/password
- Connect profile page to real API instead of mock implementation
- Export changePassword function from API index

The backend endpoint was already implemented but frontend was using
a placeholder Promise.resolve(). Now properly calls the backend API.

Refs: apps/frontend/app/dashboard/profile/page.tsx:87-105

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 17:56:10 +01:00

364 lines
14 KiB
TypeScript

/**
* User Profile Page
*
* Allows users to view and update their profile information
*/
'use client';
import { useState } 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 } = 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 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);
};
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>
);
}