diff --git a/.env.example b/.env.example index cf15766..734bf72 100644 --- a/.env.example +++ b/.env.example @@ -16,4 +16,8 @@ MINIO_CONSOLE_PORT=9001 # pgAdmin (optional) PGADMIN_DEFAULT_EMAIL=admin@fixpoint.local PGADMIN_DEFAULT_PASSWORD=admin -PGADMIN_PORT=8080 \ No newline at end of file +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 diff --git a/package-lock.json b/package-lock.json index 3635319..0637dbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", "express-validator": "^7.2.1", @@ -1792,6 +1794,12 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2961,22 +2969,22 @@ } }, "node_modules/express-validator": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", - "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", + "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", "license": "MIT", "dependencies": { "lodash": "^4.17.21", - "validator": "~13.12.0" + "validator": "~13.15.23" }, "engines": { "node": ">= 8.0.0" } }, "node_modules/express-validator/node_modules/validator": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", - "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "version": "13.15.20", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz", + "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -3315,9 +3323,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -4491,9 +4499,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -6148,6 +6156,15 @@ "node": ">=10" } }, + "node_modules/sequelize/node_modules/validator": { + "version": "13.15.20", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz", + "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/serve-static": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", @@ -7175,15 +7192,6 @@ "node": ">=10.12.0" } }, - "node_modules/validator": { - "version": "13.15.15", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", - "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 1fc4c1b..3a9d1d8 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "license": "ISC", "type": "module", "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", "express-validator": "^7.2.1", @@ -36,6 +38,8 @@ }, "overrides": { "inflight": "npm:@isaacs/inflight@^1.0.6", - "glob": "^10.0.0" + "glob": "10.5.0", + "validator": "13.15.20", + "js-yaml": "3.14.2" } -} +} \ No newline at end of file diff --git a/server.js b/server.js index 44a4618..0e4dfb9 100644 --- a/server.js +++ b/server.js @@ -1,15 +1,17 @@ import 'dotenv/config'; import express from 'express'; import http from "http"; +import cors from 'cors'; import { initializeDatabase, initializeStorage } from './src/services/connectionService.js'; import healthRoutes from './src/routes/health.js'; import { setupSocket } from './src/socket/socket.js'; import issueRoutes from './src/routes/issues.js'; import userRoutes from './src/routes/users.js'; -import messageRoutes from './src/routes/messages.js'; +import messageRoutes from './src/routes/messages.js'; import branchRoutes from './src/routes/branch.js'; import thirdPartiesRoutes from './src/routes/thirdparties.js'; import cashRequestRoutes from './src/routes/cashRequestRoutes.js'; +import authRoutes from './src/routes/auth.js'; const app = express(); const server = http.createServer(app); @@ -22,13 +24,12 @@ setupSocket(server); app.use(express.json()); app.use(express.urlencoded({ extended: true })); -// Middleware -app.use(express.json()); // Parse JSON request bodies -app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies +// Enable CORS +app.use(cors({ origin: '*' })); // Routes app.use('/api/health', healthRoutes); - +app.use('/api/v1/auth', authRoutes); app.use('/api/v1/cash-requests', cashRequestRoutes); app.use('/api/v1/issues', issueRoutes); app.use('/api/v1/users', userRoutes); @@ -60,7 +61,7 @@ async function startServer() { console.log('Initializing MinIO storage...'); await initializeStorage(); - // Start the server + // Start server server.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); console.log(`Server URL: http://localhost:${PORT}`); @@ -76,4 +77,4 @@ async function startServer() { } // Start the server - startServer(); \ No newline at end of file +startServer(); \ No newline at end of file diff --git a/src/database/seeders/20251011000000-demo-users.cjs b/src/database/seeders/20251011000000-demo-users.cjs index 5a133a8..e9c1563 100644 --- a/src/database/seeders/20251011000000-demo-users.cjs +++ b/src/database/seeders/20251011000000-demo-users.cjs @@ -1,13 +1,18 @@ 'use strict'; +const bcrypt = require('bcryptjs'); + /** @type {import('sequelize-cli').Migration} */ module.exports = { async up(queryInterface, Sequelize) { + // Hash the password once for all users (they all use the same password) + const hashedPassword = await bcrypt.hash('password123', 10); + await queryInterface.bulkInsert('Users', [ { name: 'John Technician', email: 'john.tech@dominoslk.com', - password: 'password123', + password: hashedPassword, role: 'technician', phone: '0771234567', profilePicture: null, @@ -18,7 +23,7 @@ module.exports = { { name: 'Sarah Technician', email: 'sarah.tech@dominoslk.com', - password: 'password123', + password: hashedPassword, role: 'technician', phone: '0772345678', profilePicture: null, @@ -29,7 +34,7 @@ module.exports = { { name: 'Mike Manager', email: 'mike.manager@dominoslk.com', - password: 'password123', + password: hashedPassword, role: 'branch_manager', phone: '0773456789', profilePicture: null, @@ -40,7 +45,7 @@ module.exports = { { name: 'Lisa Manager', email: 'lisa.manager@dominoslk.com', - password: 'password123', + password: hashedPassword, role: 'branch_manager', phone: '0774567890', profilePicture: null, @@ -51,7 +56,7 @@ module.exports = { { name: 'David Executive', email: 'david.exec@dominoslk.com', - password: 'password123', + password: hashedPassword, role: 'maintenance_executive', phone: '0775678901', profilePicture: null, @@ -62,7 +67,7 @@ module.exports = { { name: 'Emma Executive', email: 'emma.exec@dominoslk.com', - password: 'password123', + password: hashedPassword, role: 'maintenance_executive', phone: '0776789012', profilePicture: null, diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..452eada --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,394 @@ +import express from 'express'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; +import userService from '../services/userService.js'; + +const router = express.Router(); + +// JWT Configuration from environment variables +if (!process.env.JWT_SECRET) { + console.warn('⚠️ WARNING: JWT_SECRET not set in environment variables. Using default (INSECURE!).'); +} +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; + +/** + * @route POST /api/v1/auth/register + * @desc Register a new user + * @access Public + */ +router.post('/register', async (req, res) => { + try { + const { email, password, name, role } = req.body; + + // Validate input + if (!email || !password || !name || !role) { + return res.status(400).json({ + success: false, + message: 'Email, password, name, and role are required' + }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return res.status(400).json({ + success: false, + message: 'Please provide a valid email address' + }); + } + + // Validate password length + if (password.length < 6) { + return res.status(400).json({ + success: false, + message: 'Password must be at least 6 characters long' + }); + } + + // Validate role + const validRoles = ['technician', 'branch_manager', 'maintenance_executive']; + if (!validRoles.includes(role)) { + return res.status(400).json({ + success: false, + message: 'Invalid role. Must be technician, branch_manager, or maintenance_executive' + }); + } + + // Check if user already exists + const existingUser = await userService.getUserByEmail(email); + if (existingUser) { + return res.status(409).json({ + success: false, + message: 'User with this email already exists' + }); + } + + // Hash password with bcrypt before storing + const hashedPassword = await bcrypt.hash(password, 10); + + // Create user with role-specific profile + const userData = { + email, + password: hashedPassword, + name, + role, + isActive: true + }; + + // Role-specific profile data (can be extended) + const profileData = {}; + + const newUser = await userService.createUser(userData, profileData); + + // Generate JWT token + const token = jwt.sign( + { + id: newUser.id, + email: newUser.email, + role: newUser.role + }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); + + // Convert to plain object and remove password + const userObject = newUser.get({ plain: true }); + const { password: _, ...userWithoutPassword } = userObject; + + res.status(201).json({ + success: true, + message: 'Registration successful', + token, + data: userWithoutPassword + }); + + } catch (error) { + console.error('Registration error:', error); + + // Handle unique constraint errors + if (error.message.includes('already exists')) { + return res.status(409).json({ + success: false, + message: error.message + }); + } + + res.status(500).json({ + success: false, + message: 'Registration failed. Please try again.' + }); + } +}); + + +/** + * @route POST /api/v1/auth/login + * @desc Login user and return JWT token + * @access Public + */ +router.post('/login', async (req, res) => { + try { + const { email, password } = req.body; + + // Validate input + if (!email || !password) { + return res.status(400).json({ + success: false, + message: 'Email and password are required' + }); + } + + // Find user by email + const user = await userService.getUserByEmail(email); + + if (!user) { + return res.status(401).json({ + success: false, + message: 'Invalid email or password' + }); + } + + // Check if user is active + if (!user.isActive) { + return res.status(401).json({ + success: false, + message: 'Account is inactive. Please contact support.' + }); + } + + // Verify password using bcrypt + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + return res.status(401).json({ + success: false, + message: 'Invalid email or password' + }); + } + + // Generate JWT token + const token = jwt.sign( + { + id: user.id, + email: user.email, + role: user.role + }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); + + // Convert Sequelize model to plain object to avoid circular reference + const userObject = user.get({ plain: true }); + + // Remove password from response + const { password: _, ...userWithoutPassword } = userObject; + + res.status(200).json({ + success: true, + message: 'Login successful', + token, + data: userWithoutPassword + }); + + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ + success: false, + message: 'Login failed. Please try again.' + }); + } +}); + +/** + * @route GET /api/v1/auth/me + * @desc Get current logged-in user + * @access Protected (requires valid JWT token) + */ +router.get('/me', authenticateToken, async (req, res) => { + try { + // req.user is set by the authenticateToken middleware + const user = await userService.getUserById(req.user.id); + + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found' + }); + } + + // Convert Sequelize model to plain object to avoid circular reference + const userObject = user.get({ plain: true }); + + // Remove password from response (already excluded by getUserById, but just in case) + const { password: _, ...userWithoutPassword } = userObject; + + res.status(200).json({ + success: true, + data: userWithoutPassword + }); + + } catch (error) { + console.error('Get current user error:', error); + res.status(500).json({ + success: false, + message: 'Failed to fetch user data' + }); + } +}); + +/** + * @route POST /api/v1/auth/forgot-password + * @desc Initiate password reset process (generate reset token) + * @access Public + */ +router.post('/forgot-password', async (req, res) => { + try { + const { email } = req.body; + + // Validate input + if (!email) { + return res.status(400).json({ + success: false, + message: 'Email is required' + }); + } + + // Find user by email + const user = await userService.getUserByEmail(email); + + // Don't reveal if user exists or not (security best practice) + if (!user) { + return res.status(200).json({ + success: true, + message: 'If an account exists with this email, a password reset link 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' } + ); + + // 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) + + // TODO: Send email with reset link + // const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; + // await sendEmail(user.email, 'Password Reset', resetLink); + + 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 + }); + + } catch (error) { + console.error('Forgot password error:', error); + res.status(500).json({ + success: false, + message: 'Failed to process password reset request' + }); + } +}); + +/** + * @route POST /api/v1/auth/reset-password + * @desc Reset password using reset token + * @access Public + */ +router.post('/reset-password', async (req, res) => { + try { + const { token, newPassword } = req.body; + + // Validate input + if (!token || !newPassword) { + return res.status(400).json({ + success: false, + message: 'Token and new password are required' + }); + } + + // Validate password length + if (newPassword.length < 6) { + return res.status(400).json({ + success: false, + message: 'Password must be at least 6 characters long' + }); + } + + // 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); + + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found' + }); + } + + // Hash the new password + const hashedPassword = await bcrypt.hash(newPassword, 10); + + // Update the user's password + await userService.updateUser(decoded.id, { password: hashedPassword }, {}); + + res.status(200).json({ + success: true, + message: 'Password has been reset successfully. You can now login with your new password.' + }); + + } catch (error) { + console.error('Reset password error:', error); + res.status(500).json({ + success: false, + message: 'Failed to reset password' + }); + } +}); + +/** + * Middleware to authenticate JWT token + */ +function authenticateToken(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + if (!token) { + return res.status(401).json({ + success: false, + message: 'Access token is required' + }); + } + + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) { + return res.status(403).json({ + success: false, + message: 'Invalid or expired token' + }); + } + + req.user = user; + next(); + }); +} + +export { authenticateToken }; +export default router; diff --git a/src/services/userService.js b/src/services/userService.js index 9d8b0b8..77e2bf3 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -19,11 +19,11 @@ class UserService { */ async createUser(userData, profileData = {}) { const transaction = await sequelize.transaction(); - + try { // Create base user const user = await User.create(userData, { transaction }); - + // Create role-specific profile based on user role let profile = null; if (userData.role === 'technician') { @@ -153,6 +153,37 @@ class UserService { return user; } + /** + * Get user by email (for authentication) + * @param {string} email - User email + * @returns {Promise} User object with password included + */ + async getUserByEmail(email) { + const user = await User.findOne({ + where: { email, isActive: true }, + // Include password for authentication + include: [ + { + model: Technician, + as: 'technicianProfile', + required: false + }, + { + model: BranchManager, + as: 'branchManagerProfile', + required: false + }, + { + model: MaintenanceExecutive, + as: 'maintenanceExecutiveProfile', + required: false + } + ] + }); + + return user; + } + /** * Update user and/or profile by ID * @param {number} userId - User ID @@ -162,7 +193,7 @@ class UserService { */ async updateUser(userId, updateData, profileData = {}) { const transaction = await sequelize.transaction(); - + try { const user = await User.findOne({ where: { id: userId, isActive: true },