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:
144
backend/src/controllers/user.js
Normal file
144
backend/src/controllers/user.js
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user