All checks were successful
CI/CD Pipeline / Backend - Build, Test & Push (push) Successful in 2m42s
CI/CD Pipeline / Frontend - Build, Test & Push (push) Successful in 27m20s
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Deployment Summary (push) Successful in 1s
CI/CD Pipeline / Deploy to Portainer (push) Successful in 12s
CI/CD Pipeline / Discord Notification (Failure) (push) Has been skipped
CI/CD Pipeline / Discord Notification (Success) (push) Successful in 2s
Fixed issue where password form fields (especially "New Password") were being pre-filled with values, either from browser autocomplete or residual form state. Changes: 1. Added explicit empty defaultValues to password form - currentPassword: '' - newPassword: '' - confirmPassword: '' 2. Added autoComplete attributes to prevent browser pre-fill: - currentPassword: autoComplete="current-password" - newPassword: autoComplete="new-password" - confirmPassword: autoComplete="new-password" 3. Added useEffect to reset password form when switching tabs: - Ensures clean state when navigating to "Change Password" tab - Prevents stale values from persisting 4. Explicit reset values on successful password change: - Previously used passwordForm.reset() without values - Now explicitly sets all fields to empty strings This ensures password fields are always empty and never pre-filled by the browser or by residual form state. Refs: apps/frontend/app/dashboard/profile/page.tsx:64-70,85-95 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
429 lines
16 KiB
TypeScript
429 lines
16 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),
|
|
defaultValues: {
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
},
|
|
});
|
|
|
|
// 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]);
|
|
|
|
// Reset password form when switching to password tab
|
|
useEffect(() => {
|
|
if (activeTab === 'password') {
|
|
passwordForm.reset({
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeTab]);
|
|
|
|
// 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({
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
});
|
|
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"
|
|
autoComplete="current-password"
|
|
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"
|
|
autoComplete="new-password"
|
|
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"
|
|
autoComplete="new-password"
|
|
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>
|
|
);
|
|
}
|