From 7c2ed687c1b8c493264e7982cb32542018ce1d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Thu, 13 Nov 2025 20:26:49 +0100 Subject: [PATCH] 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 --- backend/src/controllers/user.js | 144 +++++++++ backend/src/middleware/validators.js | 32 ++ backend/src/routes/users.js | 8 + frontend/src/App.jsx | 9 + frontend/src/components/layout/Navbar.jsx | 8 + frontend/src/pages/ProfilePage.jsx | 363 ++++++++++++++++++++++ frontend/src/services/api.js | 22 ++ 7 files changed, 586 insertions(+) create mode 100644 backend/src/controllers/user.js create mode 100644 frontend/src/pages/ProfilePage.jsx diff --git a/backend/src/controllers/user.js b/backend/src/controllers/user.js new file mode 100644 index 0000000..6fb8f11 --- /dev/null +++ b/backend/src/controllers/user.js @@ -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, +}; diff --git a/backend/src/middleware/validators.js b/backend/src/middleware/validators.js index ffde79f..756031b 100644 --- a/backend/src/middleware/validators.js +++ b/backend/src/middleware/validators.js @@ -113,10 +113,42 @@ const passwordResetValidation = [ 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 = { registerValidation, loginValidation, verifyCodeValidation, passwordResetValidation, + updateProfileValidation, + changePasswordValidation, handleValidationErrors, }; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 9cd9759..cd05e59 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -1,6 +1,8 @@ const express = require('express'); const { authenticate } = require('../middleware/auth'); const { prisma } = require('../utils/db'); +const { updateProfile, changePassword } = require('../controllers/user'); +const { updateProfileValidation, changePasswordValidation } = require('../middleware/validators'); 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; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fe81e71..93a5e18 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -10,6 +10,7 @@ import EventChatPage from './pages/EventChatPage'; import MatchChatPage from './pages/MatchChatPage'; import RatePartnerPage from './pages/RatePartnerPage'; import HistoryPage from './pages/HistoryPage'; +import ProfilePage from './pages/ProfilePage'; import VerificationBanner from './components/common/VerificationBanner'; // Protected Route Component with Verification Banner @@ -122,6 +123,14 @@ function App() { } /> + + + + } + /> {/* Default redirect */} } /> diff --git a/frontend/src/components/layout/Navbar.jsx b/frontend/src/components/layout/Navbar.jsx index 104a28f..5549d9f 100644 --- a/frontend/src/components/layout/Navbar.jsx +++ b/frontend/src/components/layout/Navbar.jsx @@ -33,6 +33,14 @@ const Navbar = () => { History + + + Profile + +
{ + 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 ( + +
+
+ {/* Header */} +
+
+ {user?.username} +
+

+ {user?.firstName || user?.username} +

+

@{user?.username}

+ {!user?.emailVerified && ( +

+ ⚠️ Email not verified +

+ )} +
+
+
+ + {/* Tabs */} +
+
+
+ + +
+
+ +
+ {/* Profile Tab */} + {activeTab === 'profile' && ( +
+

Edit Profile

+ + {profileMessage && ( +
+ +

{profileMessage}

+
+ )} + + {profileError && ( +
+ +

{profileError}

+
+ )} + +
+ {/* First Name */} +
+ + +
+ + {/* Last Name */} +
+ + +
+
+ + {/* Email */} +
+ +
+
+ +
+ +
+

+ Changing your email will require re-verification +

+
+ + {/* Submit Button */} + +
+ )} + + {/* Password Tab */} + {activeTab === 'password' && ( +
+

Change Password

+ + {passwordMessage && ( +
+ +

{passwordMessage}

+
+ )} + + {passwordError && ( +
+ +

{passwordError}

+
+ )} + + {/* Current Password */} +
+ +
+
+ +
+ +
+
+ + {/* New Password */} +
+ +
+
+ +
+ +
+
+ + {/* Confirm Password */} +
+ +
+
+ +
+ +
+
+ + {/* Submit Button */} + +
+ )} +
+
+
+
+
+ ); +}; + +export default ProfilePage; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 56d88f2..6f03549 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -138,6 +138,28 @@ export const authAPI = { 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() { localStorage.removeItem('token'); localStorage.removeItem('user');