feat: add user profile editing with email re-verification

Backend changes:
- Add PATCH /api/users/me endpoint for profile updates (firstName, lastName, email)
- Add PATCH /api/users/me/password endpoint for password change
- Email change triggers re-verification flow (emailVerified=false, new verification token/code)
- Send verification email automatically on email change
- Return new JWT token when email changes (to update emailVerified status)
- Add validation for profile update and password change
- Create user controller with updateProfile and changePassword functions

Frontend changes:
- Add ProfilePage with tabbed interface (Profile & Password tabs)
- Profile tab: Edit firstName, lastName, email
- Password tab: Change password (requires current password)
- Add Profile link to navigation bar
- Add authAPI.updateProfile() and authAPI.changePassword() functions
- Update AuthContext user data when profile is updated
- Display success/error messages for profile and password updates

Security:
- Username cannot be changed (permanent identifier)
- Email uniqueness validation
- Password change requires current password
- Email change forces re-verification to prevent hijacking

User flow:
1. User edits profile and changes email
2. Backend sets emailVerified=false and generates new verification tokens
3. Verification email sent to new address
4. User must verify new email to access all features
5. Banner appears until email is verified
This commit is contained in:
Radosław Gierwiało
2025-11-13 20:26:49 +01:00
parent 9d8fc9f6d6
commit 7c2ed687c1
7 changed files with 586 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
const { prisma } = require('../utils/db');
const { hashPassword, comparePassword, generateToken, generateVerificationToken, generateVerificationCode } = require('../utils/auth');
const { sendVerificationEmail } = require('../utils/email');
const { sanitizeForEmail } = require('../utils/sanitize');
/**
* Update user profile
* PATCH /api/users/me
*/
async function updateProfile(req, res, next) {
try {
const userId = req.user.id;
const { firstName, lastName, email } = req.body;
// Build update data
const updateData = {};
if (firstName !== undefined) updateData.firstName = firstName;
if (lastName !== undefined) updateData.lastName = lastName;
// Check if email is being changed
const currentUser = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
let emailChanged = false;
if (email && email !== currentUser.email) {
// Check if new email is already taken by another user
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser && existingUser.id !== userId) {
return res.status(400).json({
success: false,
error: 'Email is already registered to another account',
});
}
// Email is being changed - require re-verification
updateData.email = email;
updateData.emailVerified = false;
updateData.verificationToken = generateVerificationToken();
updateData.verificationCode = generateVerificationCode();
updateData.verificationTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
emailChanged = true;
}
// Update user
const updatedUser = await prisma.user.update({
where: { id: userId },
data: updateData,
});
// If email changed, send verification email
if (emailChanged) {
try {
await sendVerificationEmail(
updatedUser.email,
sanitizeForEmail(updatedUser.firstName || updatedUser.username),
updatedUser.verificationToken,
updatedUser.verificationCode
);
} catch (emailError) {
console.error('Failed to send verification email:', emailError);
// Continue anyway - user is updated but email might not have been sent
}
}
// Generate new JWT token (in case emailVerified changed)
const token = generateToken({ userId: updatedUser.id });
// Remove sensitive data
const { passwordHash, verificationToken, verificationCode, verificationTokenExpiry, resetToken, resetTokenExpiry, ...userWithoutPassword } = updatedUser;
res.json({
success: true,
message: emailChanged
? 'Profile updated. Please verify your new email address.'
: 'Profile updated successfully',
data: {
user: userWithoutPassword,
token,
emailChanged,
},
});
} catch (error) {
next(error);
}
}
/**
* Change password
* PATCH /api/users/me/password
*/
async function changePassword(req, res, next) {
try {
const userId = req.user.id;
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({
success: false,
error: 'Current password and new password are required',
});
}
// Get user with password
const user = await prisma.user.findUnique({
where: { id: userId },
});
// Verify current password
const isPasswordValid = await comparePassword(currentPassword, user.passwordHash);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
error: 'Current password is incorrect',
});
}
// Hash new password
const hashedPassword = await hashPassword(newPassword);
// Update password
await prisma.user.update({
where: { id: userId },
data: { passwordHash: hashedPassword },
});
res.json({
success: true,
message: 'Password changed successfully',
});
} catch (error) {
next(error);
}
}
module.exports = {
updateProfile,
changePassword,
};

View File

@@ -113,10 +113,42 @@ const passwordResetValidation = [
handleValidationErrors, handleValidationErrors,
]; ];
// Update profile validation
const updateProfileValidation = [
body('firstName')
.optional()
.trim()
.isLength({ min: 1, max: 50 })
.withMessage('First name must be between 1 and 50 characters'),
body('lastName')
.optional()
.trim()
.isLength({ min: 1, max: 50 })
.withMessage('Last name must be between 1 and 50 characters'),
body('email')
.optional()
.trim()
.isEmail()
.withMessage('Must be a valid email address')
.normalizeEmail(),
handleValidationErrors,
];
// Change password validation
const changePasswordValidation = [
body('currentPassword')
.notEmpty()
.withMessage('Current password is required'),
buildPasswordValidation('newPassword'),
handleValidationErrors,
];
module.exports = { module.exports = {
registerValidation, registerValidation,
loginValidation, loginValidation,
verifyCodeValidation, verifyCodeValidation,
passwordResetValidation, passwordResetValidation,
updateProfileValidation,
changePasswordValidation,
handleValidationErrors, handleValidationErrors,
}; };

View File

@@ -1,6 +1,8 @@
const express = require('express'); const express = require('express');
const { authenticate } = require('../middleware/auth'); const { authenticate } = require('../middleware/auth');
const { prisma } = require('../utils/db'); const { prisma } = require('../utils/db');
const { updateProfile, changePassword } = require('../controllers/user');
const { updateProfileValidation, changePasswordValidation } = require('../middleware/validators');
const router = express.Router(); const router = express.Router();
@@ -64,4 +66,10 @@ router.get('/me', authenticate, async (req, res, next) => {
} }
}); });
// PATCH /api/users/me - Update user profile
router.patch('/me', authenticate, updateProfileValidation, updateProfile);
// PATCH /api/users/me/password - Change password
router.patch('/me/password', authenticate, changePasswordValidation, changePassword);
module.exports = router; module.exports = router;

View File

@@ -10,6 +10,7 @@ import EventChatPage from './pages/EventChatPage';
import MatchChatPage from './pages/MatchChatPage'; import MatchChatPage from './pages/MatchChatPage';
import RatePartnerPage from './pages/RatePartnerPage'; import RatePartnerPage from './pages/RatePartnerPage';
import HistoryPage from './pages/HistoryPage'; import HistoryPage from './pages/HistoryPage';
import ProfilePage from './pages/ProfilePage';
import VerificationBanner from './components/common/VerificationBanner'; import VerificationBanner from './components/common/VerificationBanner';
// Protected Route Component with Verification Banner // Protected Route Component with Verification Banner
@@ -122,6 +123,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
{/* Default redirect */} {/* Default redirect */}
<Route path="/" element={<Navigate to="/events" replace />} /> <Route path="/" element={<Navigate to="/events" replace />} />

View File

@@ -33,6 +33,14 @@ const Navbar = () => {
<span>History</span> <span>History</span>
</Link> </Link>
<Link
to="/profile"
className="flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-100"
>
<User className="w-4 h-4" />
<span>Profile</span>
</Link>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<img <img
src={user.avatar} src={user.avatar}

View File

@@ -0,0 +1,363 @@
import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { authAPI } from '../services/api';
import Layout from '../components/layout/Layout';
import { User, Mail, Lock, Save, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
const ProfilePage = () => {
const { user, updateUser } = useAuth();
const [activeTab, setActiveTab] = useState('profile');
// Profile edit state
const [profileData, setProfileData] = useState({
firstName: user?.firstName || '',
lastName: user?.lastName || '',
email: user?.email || '',
});
const [profileLoading, setProfileLoading] = useState(false);
const [profileMessage, setProfileMessage] = useState('');
const [profileError, setProfileError] = useState('');
// Password change state
const [passwordData, setPasswordData] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordMessage, setPasswordMessage] = useState('');
const [passwordError, setPasswordError] = useState('');
const handleProfileChange = (e) => {
setProfileData({ ...profileData, [e.target.name]: e.target.value });
setProfileMessage('');
setProfileError('');
};
const handlePasswordChange = (e) => {
setPasswordData({ ...passwordData, [e.target.name]: e.target.value });
setPasswordMessage('');
setPasswordError('');
};
const handleProfileSubmit = async (e) => {
e.preventDefault();
setProfileLoading(true);
setProfileMessage('');
setProfileError('');
try {
const response = await authAPI.updateProfile(profileData);
if (response.success) {
// Update context with new user data
if (response.data.user) {
updateUser(response.data.user);
}
setProfileMessage(response.message);
if (response.data.emailChanged) {
setProfileMessage(
'Profile updated! Please check your new email address to verify it.'
);
}
}
} catch (error) {
setProfileError(error.data?.error || 'Failed to update profile');
} finally {
setProfileLoading(false);
}
};
const handlePasswordSubmit = async (e) => {
e.preventDefault();
setPasswordLoading(true);
setPasswordMessage('');
setPasswordError('');
// Validate passwords match
if (passwordData.newPassword !== passwordData.confirmPassword) {
setPasswordError('New passwords do not match');
setPasswordLoading(false);
return;
}
try {
const response = await authAPI.changePassword(
passwordData.currentPassword,
passwordData.newPassword
);
if (response.success) {
setPasswordMessage(response.message);
// Clear form
setPasswordData({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
}
} catch (error) {
setPasswordError(error.data?.error || 'Failed to change password');
} finally {
setPasswordLoading(false);
}
};
return (
<Layout>
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Header */}
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
<div className="flex items-center gap-4">
<img
src={user?.avatar}
alt={user?.username}
className="w-20 h-20 rounded-full"
/>
<div>
<h1 className="text-2xl font-bold text-gray-900">
{user?.firstName || user?.username}
</h1>
<p className="text-gray-600">@{user?.username}</p>
{!user?.emailVerified && (
<p className="text-sm text-yellow-600 mt-1">
Email not verified
</p>
)}
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow-sm">
<div className="border-b">
<div className="flex">
<button
onClick={() => setActiveTab('profile')}
className={`px-6 py-3 font-medium ${
activeTab === 'profile'
? 'border-b-2 border-primary-600 text-primary-600'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<User className="w-5 h-5 inline mr-2" />
Profile
</button>
<button
onClick={() => setActiveTab('password')}
className={`px-6 py-3 font-medium ${
activeTab === 'password'
? 'border-b-2 border-primary-600 text-primary-600'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Lock className="w-5 h-5 inline mr-2" />
Password
</button>
</div>
</div>
<div className="p-6">
{/* Profile Tab */}
{activeTab === 'profile' && (
<form onSubmit={handleProfileSubmit} className="space-y-4">
<h2 className="text-xl font-semibold mb-4">Edit Profile</h2>
{profileMessage && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md flex items-start gap-2">
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-600">{profileMessage}</p>
</div>
)}
{profileError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600">{profileError}</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* First Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
First Name
</label>
<input
type="text"
name="firstName"
value={profileData.firstName}
onChange={handleProfileChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* Last Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Last Name
</label>
<input
type="text"
name="lastName"
value={profileData.lastName}
onChange={handleProfileChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
type="email"
name="email"
value={profileData.email}
onChange={handleProfileChange}
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Changing your email will require re-verification
</p>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={profileLoading}
className="w-full flex items-center justify-center gap-2 py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
{profileLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-5 h-5" />
Save Changes
</>
)}
</button>
</form>
)}
{/* Password Tab */}
{activeTab === 'password' && (
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<h2 className="text-xl font-semibold mb-4">Change Password</h2>
{passwordMessage && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md flex items-start gap-2">
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-600">{passwordMessage}</p>
</div>
)}
{passwordError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600">{passwordError}</p>
</div>
)}
{/* Current Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Current Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
type="password"
name="currentPassword"
value={passwordData.currentPassword}
onChange={handlePasswordChange}
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
required
/>
</div>
</div>
{/* New Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
type="password"
name="newPassword"
value={passwordData.newPassword}
onChange={handlePasswordChange}
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
required
/>
</div>
</div>
{/* Confirm Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
type="password"
name="confirmPassword"
value={passwordData.confirmPassword}
onChange={handlePasswordChange}
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
required
/>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={passwordLoading}
className="w-full flex items-center justify-center gap-2 py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
{passwordLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Changing...
</>
) : (
<>
<Lock className="w-5 h-5" />
Change Password
</>
)}
</button>
</form>
)}
</div>
</div>
</div>
</div>
</Layout>
);
};
export default ProfilePage;

View File

@@ -138,6 +138,28 @@ export const authAPI = {
return data; return data;
}, },
async updateProfile(profileData) {
const data = await fetchAPI('/users/me', {
method: 'PATCH',
body: JSON.stringify(profileData),
});
// Update token if it was returned (email changed)
if (data.data?.token) {
localStorage.setItem('token', data.data.token);
}
return data;
},
async changePassword(currentPassword, newPassword) {
const data = await fetchAPI('/users/me/password', {
method: 'PATCH',
body: JSON.stringify({ currentPassword, newPassword }),
});
return data;
},
logout() { logout() {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');