From b927b3f5d9c0267b639e2534c6dec7c8c3040181 Mon Sep 17 00:00:00 2001 From: naveen sanjula <82176749+naveensanjula975@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:56:22 +0530 Subject: [PATCH 1/4] feat: implement OTP-based password reset fields with a new email service. --- .env.example | 11 +- package-lock.json | 10 ++ package.json | 3 +- .../20251220000000-add-reset-otp-fields.cjs | 22 ++++ .../20251220165748-add-reset-otp-fields.js | 22 ++++ src/models/user.js | 10 ++ src/routes/auth.js | 90 ++++++++----- src/services/emailService.js | 124 ++++++++++++++++++ 8 files changed, 256 insertions(+), 36 deletions(-) create mode 100644 src/database/migrations/20251220000000-add-reset-otp-fields.cjs create mode 100644 src/database/migrations/20251220165748-add-reset-otp-fields.js create mode 100644 src/services/emailService.js diff --git a/.env.example b/.env.example index 734bf72..e6ef7e6 100644 --- a/.env.example +++ b/.env.example @@ -20,4 +20,13 @@ PGADMIN_PORT=8080 # JWT Configuration JWT_SECRET=your-super-secret-jwt-key-change-this-in-production-min-32-chars -JWT_EXPIRES_IN=7d \ No newline at end of file +JWT_EXPIRES_IN=7d + +# SMTP Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password +SMTP_SECURE=false +EMAIL_FROM=no-reply@fixpoint.com +EMAIL_FROM_NAME=FixPoint_Support \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cf84e24..babd5cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "jsonwebtoken": "^9.0.2", "minio": "^8.0.6", "multer": "^2.0.2", + "nodemailer": "^7.0.11", "pg": "^8.16.3", "pg-hstore": "^2.3.4", "sequelize": "^6.37.7", @@ -5071,6 +5072,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", diff --git a/package.json b/package.json index 7096a13..f02646a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "jsonwebtoken": "^9.0.2", "minio": "^8.0.6", "multer": "^2.0.2", + "nodemailer": "^7.0.11", "pg": "^8.16.3", "pg-hstore": "^2.3.4", "sequelize": "^6.37.7", @@ -42,4 +43,4 @@ "validator": "^13.15.22", "js-yaml": "3.14.2" } -} \ No newline at end of file +} diff --git a/src/database/migrations/20251220000000-add-reset-otp-fields.cjs b/src/database/migrations/20251220000000-add-reset-otp-fields.cjs new file mode 100644 index 0000000..dad9875 --- /dev/null +++ b/src/database/migrations/20251220000000-add-reset-otp-fields.cjs @@ -0,0 +1,22 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('Users', 'resetOTP', { + type: Sequelize.STRING(10), + allowNull: true, + comment: '6-digit OTP for password reset' + }); + + await queryInterface.addColumn('Users', 'resetOTPExpiry', { + type: Sequelize.DATE, + allowNull: true, + comment: 'Expiration time of the reset OTP' + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('Users', 'resetOTP'); + await queryInterface.removeColumn('Users', 'resetOTPExpiry'); + } +}; diff --git a/src/database/migrations/20251220165748-add-reset-otp-fields.js b/src/database/migrations/20251220165748-add-reset-otp-fields.js new file mode 100644 index 0000000..b6e01de --- /dev/null +++ b/src/database/migrations/20251220165748-add-reset-otp-fields.js @@ -0,0 +1,22 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + /** + * Add altering commands here. + * + * Example: + * await queryInterface.createTable('users', { id: Sequelize.INTEGER }); + */ + }, + + async down (queryInterface, Sequelize) { + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + } +}; diff --git a/src/models/user.js b/src/models/user.js index 1beea04..a2b4263 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -78,6 +78,16 @@ const User = sequelize.define( type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true + }, + resetOTP: { + type: DataTypes.STRING(10), + allowNull: true, + comment: '6-digit OTP for password reset' + }, + resetOTPExpiry: { + type: DataTypes.DATE, + allowNull: true, + comment: 'Expiration time of the reset OTP' } }, { diff --git a/src/routes/auth.js b/src/routes/auth.js index 9080dd6..24fe7a6 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -1,7 +1,9 @@ import express from 'express'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; + import userService from '../services/userService.js'; +import emailService from '../services/emailService.js'; const router = express.Router(); @@ -285,33 +287,43 @@ router.post('/forgot-password', async (req, res) => { // Don't reveal if user exists or not (security best practice) if (!user) { + // Fake success for security (prevents user enumeration) return res.status(200).json({ success: true, - message: 'If an account exists with this email, a password reset link has been sent.' + message: 'If an account exists with this email, a password reset code has been sent.' }); } - // Generate a password reset token (valid for 1 hour) - const resetToken = jwt.sign( - { id: user.id, email: user.email }, - JWT_SECRET, - { expiresIn: '1h' } - ); + // Generate 6-digit OTP + const otp = Math.floor(100000 + Math.random() * 900000).toString(); - // In a production app, you would: - // 1. Save this token to the database with an expiry time - // 2. Send an email with a reset link containing the token - // For now, we'll return the token directly (for demo purposes) + // Expiry: 15 minutes from now + const otpExpiry = new Date(Date.now() + 15 * 60 * 1000); - // TODO: Send email with reset link - // const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; - // await sendEmail(user.email, 'Password Reset', resetLink); + // Save OTP to database + await userService.updateUser(user.id, { + resetOTP: otp, + resetOTPExpiry: otpExpiry + }); + + // Send Email + try { + await emailService.sendPasswordResetEmail(user.email, otp, user.name); + } catch (emailError) { + console.error('Failed to send email:', emailError); + // In a real scenario, you might want to rollback the DB change or return an error, + // but for security "blind" responses, we might still return success or a generic error. + // Here we return 500 because if email fails, user can't reset. + return res.status(500).json({ + success: false, + message: 'Failed to send verification email. Please try again.' + }); + } res.status(200).json({ success: true, - message: 'If an account exists with this email, a password reset link has been sent.', - // For demo: return token (remove in production!) - resetToken: resetToken + // SECURITY: Do NOT return the OTP in the response + message: 'If an account exists with this email, a password reset code has been sent.' }); } catch (error) { @@ -330,13 +342,13 @@ router.post('/forgot-password', async (req, res) => { */ router.post('/reset-password', async (req, res) => { try { - const { token, newPassword } = req.body; + const { email, otp, newPassword } = req.body; // Validate input - if (!token || !newPassword) { + if (!email || !otp || !newPassword) { return res.status(400).json({ success: false, - message: 'Token and new password are required' + message: 'Email, OTP, and new password are required' }); } @@ -348,19 +360,8 @@ router.post('/reset-password', async (req, res) => { }); } - // Verify the reset token - let decoded; - try { - decoded = jwt.verify(token, JWT_SECRET); - } catch (err) { - return res.status(400).json({ - success: false, - message: 'Invalid or expired reset token' - }); - } - // Find the user - const user = await userService.getUserById(decoded.id); + const user = await userService.getUserByEmail(email); if (!user) { return res.status(404).json({ @@ -369,11 +370,32 @@ router.post('/reset-password', async (req, res) => { }); } + // Verify OTP logic + // 1. Check if OTP matches + if (!user.resetOTP || user.resetOTP !== otp) { + return res.status(400).json({ + success: false, + message: 'Invalid OTP code' + }); + } + + // 2. Check expiry + if (!user.resetOTPExpiry || new Date() > new Date(user.resetOTPExpiry)) { + return res.status(400).json({ + success: false, + message: 'OTP code has expired' + }); + } + // Hash the new password const hashedPassword = await bcrypt.hash(newPassword, 10); - // Update the user's password - await userService.updateUser(decoded.id, { password: hashedPassword }, {}); + // Update the user's password and clear OTP + await userService.updateUser(user.id, { + password: hashedPassword, + resetOTP: null, + resetOTPExpiry: null + }); res.status(200).json({ success: true, diff --git a/src/services/emailService.js b/src/services/emailService.js new file mode 100644 index 0000000..b15937f --- /dev/null +++ b/src/services/emailService.js @@ -0,0 +1,124 @@ +import nodemailer from 'nodemailer'; + +/** + * Email Service for sending emails using Nodemailer + */ + +// Create reusable transporter +const createTransporter = () => { + // Check if SMTP settings are present + if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASS) { + console.warn('⚠️ SMTP settings not missing. Email sending functionality might fail.'); + } + + return nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || '587'), + secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + tls: { + // Do not fail on invalid certs (useful for dev with self-signed certs) + // In production, you might want to remove this + rejectUnauthorized: false + } + }); +}; + +/** + * Send password reset OTP email + * @param {string} to - Recipient email address + * @param {string} otp - 6-digit OTP + * @param {string} userName - User's name + * @returns {Promise} + */ +export const sendPasswordResetEmail = async (to, otp, userName = 'User') => { + try { + const transporter = createTransporter(); + + // Email HTML template + const htmlContent = ` + + + + + + Password Reset + + + +
+
+ +

Password Reset Code

+
+ +
+

Hello ${userName},

+ +

We received a request to reset your password. Use the code below to complete the process:

+ +
+ ${otp} +
+ +
+ ⏰ Expires in 15 minutes.
+ If you didn't request this code, you can safely ignore this email. +
+
+ + +
+ + + `; + + const textContent = ` +Password Reset Code + +Hello ${userName}, + +Your password reset code is: ${otp} + +This code expires in 15 minutes. +If you didn't request this, please ignore this email. + +FixPoint Maintenance Management System + `; + + // Send email + const info = await transporter.sendMail({ + from: `"${process.env.EMAIL_FROM_NAME || 'FixPoint Support'}" <${process.env.EMAIL_FROM || process.env.SMTP_USER}>`, + to: to, + subject: 'Your Password Reset Code - FixPoint', + text: textContent, + html: htmlContent, + }); + + console.log('✅ Password reset email sent successfully:', info.messageId); + return info; + + } catch (error) { + console.error('❌ Error sending password reset email:', error); + throw new Error('Failed to send password reset email'); + } +}; + +export default { + sendPasswordResetEmail +}; From 19b7b3582c4b869cc0652a57aaec813e5b1a12ce Mon Sep 17 00:00:00 2001 From: Naveen Sanjula <82176749+naveensanjula975@users.noreply.github.com> Date: Sat, 10 Jan 2026 19:52:08 +0530 Subject: [PATCH 2/4] feat: profile photo update --- README.md | 19 +++++++++++++++++++ src/middleware/upload.js | 12 ++++++++++-- src/middleware/validation.js | 4 ++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dce7743..92f1603 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,25 @@ npm run dev The server default route is: `http://localhost:5000/api/`. +## Database Setup & Seeding + +If you are running the project for the first time or need to reset your database, follow these steps: + +1. **Run Migrations**: Create the necessary tables in the database. + ```bash + npm run db:migrate + ``` + +2. **Seed Data**: Populate the database with demo users and branches. + ```bash + npm run db:seed + ``` + +**To reset the entire database (Undo all migrations, remigrate, and re-seed):** +```bash +npm run db:reset +``` + ## Health endpoints The project exposes health endpoints mounted under `/api/health`: diff --git a/src/middleware/upload.js b/src/middleware/upload.js index ee87491..4c34817 100644 --- a/src/middleware/upload.js +++ b/src/middleware/upload.js @@ -6,18 +6,26 @@ import multer from 'multer'; // File filter - only allow images const fileFilter = (req, file, cb) => { + console.log('Multer file filter - Received file:', { + fieldname: file.fieldname, + originalname: file.originalname, + mimetype: file.mimetype, + encoding: file.encoding + }); + const allowedMimeTypes = [ 'image/jpeg', 'image/jpg', 'image/png', - 'image/gif', 'image/webp' ]; if (allowedMimeTypes.includes(file.mimetype)) { + console.log('✅ File type accepted:', file.mimetype); cb(null, true); } else { - cb(new Error('Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.'), false); + console.log('❌ File type rejected:', file.mimetype); + cb(new Error('Invalid file type. Only JPEG, PNG, and WebP images are allowed.'), false); } }; diff --git a/src/middleware/validation.js b/src/middleware/validation.js index cda27e9..2a8c394 100644 --- a/src/middleware/validation.js +++ b/src/middleware/validation.js @@ -4,8 +4,12 @@ import { body, param, validationResult } from 'express-validator'; * Middleware to handle validation errors */ export const handleValidationErrors = (req, res, next) => { + console.log('🔍 Validation middleware - Request body:', JSON.stringify(req.body)); + console.log('🔍 Validation middleware - Request file:', req.file ? req.file.originalname : 'No file'); + const errors = validationResult(req); if (!errors.isEmpty()) { + console.log('❌ Validation errors:', JSON.stringify(errors.array())); return res.status(400).json({ success: false, errors: errors.array().map(err => ({ From 47ebac0de0f75a06b59ac54e3d978dbcff7df922 Mon Sep 17 00:00:00 2001 From: Naveen Sanjula <82176749+naveensanjula975@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:03:32 +0530 Subject: [PATCH 3/4] feat: update migration file --- ...eset-otp-fields.js => 20251220165748-add-reset-otp-fields.cjs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/database/migrations/{20251220165748-add-reset-otp-fields.js => 20251220165748-add-reset-otp-fields.cjs} (100%) diff --git a/src/database/migrations/20251220165748-add-reset-otp-fields.js b/src/database/migrations/20251220165748-add-reset-otp-fields.cjs similarity index 100% rename from src/database/migrations/20251220165748-add-reset-otp-fields.js rename to src/database/migrations/20251220165748-add-reset-otp-fields.cjs From df77c0b9e99b1034e9e0affc0e5fecba73e1c24f Mon Sep 17 00:00:00 2001 From: Naveen Sanjula <82176749+naveensanjula975@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:12:11 +0530 Subject: [PATCH 4/4] feat: update package dependencies --- package-lock.json | 23 +++++++++++++---------- package.json | 3 ++- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ce3cb1..5f5bee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1913,6 +1914,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3036,9 +3038,9 @@ "license": "MIT" }, "node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", + "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", "funding": [ { "type": "github", @@ -3047,7 +3049,7 @@ ], "license": "MIT", "dependencies": { - "strnum": "^1.1.1" + "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -4698,9 +4700,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.includes": { @@ -5500,6 +5502,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -6883,9 +6886,9 @@ } }, "node_modules/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 86652b4..cf14880 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "inflight": "npm:@isaacs/inflight@^1.0.6", "glob": "10.5.0", "validator": "^13.15.22", - "js-yaml": "3.14.2" + "js-yaml": "3.14.2", + "fast-xml-parser": "5.3.4" } }