diff --git a/Documentation/Auth-API.md b/Documentation/Auth-API.md index f07ce72..74604e7 100644 --- a/Documentation/Auth-API.md +++ b/Documentation/Auth-API.md @@ -1,23 +1,48 @@ -# Auth API +# Authentication API -#### Request OTP +The Authentication API provides OTP-based login, access-token renewal, password recovery, current-user lookup, and logout. -**Endpoint** +## Base URL -``` -POST: http://localhost:3070/api/auth/req-otp +```text +http://localhost:3070/api/auth ``` -**Request Body** +Requests and responses use JSON unless otherwise stated. + +## Authentication + +After a successful login, the API returns an access token in the response and sets two HTTP-only cookies: + +- `access_token` — valid for 15 minutes +- `refresh_token` — valid for 7 days + +Protected endpoints accept the access token through the `access_token` cookie or this header: + +```http +Authorization: Bearer +``` + +When using cookie authentication from a browser, send requests with credentials enabled. + +--- + +## Request OTP + +Validates the user's email and password, then sends a one-time password to the registered email address. + +**Endpoint:** `POST` [http://localhost:3070/api/auth/req-otp](http://localhost:3070/api/auth/req-otp) + +### Request body ```json { - "email": "sathira@niolla.lk", + "email": "sathira@niolla.lk", "password": "Niolla@123" } ``` -**Respond** +### Success response — `201 Created` ```json { @@ -26,26 +51,30 @@ POST: http://localhost:3070/api/auth/req-otp } ``` +### Error responses + +- `401 Unauthorized` — invalid password +- `404 Not Found` — user not found +- `500 Internal Server Error` — OTP generation or email delivery failed + --- -#### Login +## Login -**Endpoint** +Verifies the emailed OTP and creates an authenticated session. The user account must be active. -``` -POST: http://localhost:3070/api/auth/login -``` +**Endpoint:** `POST` [http://localhost:3070/api/auth/login](http://localhost:3070/api/auth/login) -**Request Body** +### Request body ```json { - "email": "sathira@niolla.lk", + "email": "sathira@niolla.lk", "otp": "922304" } ``` -**Respond** +### Success response — `200 OK` ```json { @@ -54,33 +83,40 @@ POST: http://localhost:3070/api/auth/login "data": { "id": "usr_5ff8afec-5ddc-47d9-a63f-b43df0d8c3b4", "email": "sathira@niolla.lk", - "firstName": "Jhon", - "lastName": "Doe", - "role": "System Developer", - "accountType": "admin" + "firstName": "Sathira", + "lastName": "Sri Sathsara", + "role": null, + "accountType": "admin", + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } } ``` +The response also sets the `access_token` and `refresh_token` HTTP-only cookies. + +### Error responses + +- `400 Bad Request` — email or OTP is missing +- `401 Unauthorized` — OTP is invalid or expired +- `403 Forbidden` — account is not active +- `404 Not Found` — user not found +- `500 Internal Server Error` — login failed unexpectedly + --- -#### Get Current User +## Get Current User -**Endpoint** +Returns the authenticated user's token claims and effective permissions. -``` -GET: http://localhost:3070/api/auth/me -``` +**Endpoint:** `GET` [http://localhost:3070/api/auth/me](http://localhost:3070/api/auth/me) -**Authorization**: Required (Bearer token) +**Authentication:** Required -**Request Body** +### Request body -``` -No Body -``` +No request body. -**Response (200)** +### Success response — `200 OK` ```json { @@ -90,7 +126,6 @@ No Body "firstName": "Sathira", "lastName": "Sri Sathsara", "email": "sathira@niolla.lk", - "role": "System Developer", "accountType": "admin", "iat": 1778865265, "exp": 1778868865, @@ -99,27 +134,215 @@ No Body } ``` +### Error responses + +- `401 Unauthorized` — token is missing, invalid, or expired + --- -### Logout +## Refresh Session -**Endpoint** +Uses the HTTP-only refresh-token cookie to rotate the session and issue new access and refresh cookies. -``` -POST: http://localhost:3070/api/auth/logout +**Endpoint:** `POST` [http://localhost:3070/api/auth/refresh](http://localhost:3070/api/auth/refresh) + +### Request body + +No request body. The `refresh_token` cookie is required. + +### Success response — `200 OK` + +```json +{ + "success": true, + "message": "Session refreshed successfully" +} ``` -**Request Body** +### Error response — `401 Unauthorized` -``` -No Body +Returned when the refresh cookie is missing or the session is invalid, expired, or no longer active. + +--- + +## Forgot Password + +Sends a password-reset link when an account exists for the supplied email. The same success response is returned for unknown email addresses to prevent account discovery. + +**Endpoint:** `POST` [http://localhost:3070/api/auth/forgot-password](http://localhost:3070/api/auth/forgot-password) + +**Authentication:** Not required + +### Request body + +```json +{ + "email": "sathira@niolla.lk" +} ``` -**Respond** +### Success response — `200 OK` + +```json +{ + "success": true, + "message": "If an account exists for this email, a password reset link has been sent." +} +``` + +### Error responses + +- `400 Bad Request` — email is missing or invalid +- `500 Internal Server Error` — the reset request could not be processed + +--- + +## Reset Password + +Sets a new password using the token from the password-reset email. A successful reset invalidates all existing refresh sessions for the user. + +**Endpoint:** `POST` [http://localhost:3070/api/auth/reset-password](http://localhost:3070/api/auth/reset-password) + +**Authentication:** Not required + +### Request body + +```json +{ + "token": "password-reset-token", + "newPassword": "NewPassword@1234", + "confirmPassword": "NewPassword@1234" +} +``` + +The new password must contain at least one uppercase letter, one lowercase letter, one symbol, and four digits. It must differ from the current password. + +### Success response — `200 OK` + +```json +{ + "success": true, + "message": "Password reset successfully. Please login again." +} +``` + +### Error responses + +- `400 Bad Request` — fields are missing, passwords do not match, password rules are not met, the new password matches the current password, or the token is invalid or expired +- `500 Internal Server Error` — password reset failed unexpectedly + +--- + +## Change Password + +Changes the authenticated user's password. After a successful change, all refresh sessions are revoked, authentication cookies are cleared, and the user must log in again. + +**Endpoint:** `POST` [http://localhost:3070/api/auth/change-password](http://localhost:3070/api/auth/change-password) + +**Authentication:** Required + +The access token may be supplied through the `access_token` cookie or as a Bearer token: + +```http +Authorization: Bearer +``` + +### Request body + +```json +{ + "currentPassword": "CurrentPassword@1234", + "newPassword": "NewPassword@5678", + "confirmPassword": "NewPassword@5678" +} +``` + +The new password: + +- Must match `confirmPassword` +- Must differ from the current password +- Must contain at least one uppercase letter +- Must contain at least one lowercase letter +- Must contain at least one symbol +- Must contain at least four digits + +### Success response — `200 OK` + +```json +{ + "success": true, + "message": "Password changed successfully. Please login again." +} +``` + +### Error responses + +#### `400 Bad Request` + +Returned when required fields are missing, the passwords do not match, the new password does not satisfy the password policy, or it matches the current password. + +```json +{ + "success": false, + "message": "New password and confirm password do not match" +} +``` + +#### `401 Unauthorized` + +Returned when authentication fails or the current password is incorrect. + +```json +{ + "success": false, + "message": "Current password is incorrect" +} +``` + +#### `404 Not Found` + +```json +{ + "success": false, + "message": "User not found" +} +``` + +#### `500 Internal Server Error` + +```json +{ + "success": false, + "message": "Failed to change password" +} +``` + +--- + +## Logout + +Deletes the current refresh session when available and clears both authentication cookies. + +**Endpoint:** `POST` [http://localhost:3070/api/auth/logout](http://localhost:3070/api/auth/logout) + +### Request body + +No request body. + +### Success response — `200 OK` ```json { "success": true, "message": "Logged out successfully" } -``` \ No newline at end of file +``` + +### Error response — `500 Internal Server Error` + +```json +{ + "success": false, + "message": "Failed to logout" +} +``` diff --git a/Documentation/User-API.md b/Documentation/User-API.md index f57aa43..c18a991 100644 --- a/Documentation/User-API.md +++ b/Documentation/User-API.md @@ -18,7 +18,9 @@ emailVerifiedAt = null POST: http://localhost:3070/api/user ``` -**Request Body** +**Request Body customer** + +accontType = customer and bussiness_customer ```json { @@ -26,6 +28,10 @@ POST: http://localhost:3070/api/user "lastName": "Bimsara", "email": "ibimsara00@gmail.com", "password": "Hello@12346" + "accountType": "customer", + + "address": "Colombo, Sri Lanka", + "phoneNumber": "0771234567" } ``` @@ -45,21 +51,20 @@ POST: http://localhost:3070/api/user } ``` -After registration, the user receives an email containing a verification link. -``` -#### Verify Email +After registration, the user receives an email containing a verification link. + + + +#### Verify Email Verifies the customer's email using the raw verification token received by email. -The backend hashes the received token and compares the resulting hash with the `tokenHash` stored in the `email_verifications` table. The token must: ```text -Exist in the database -Not have been used -Not have expired +store in redis and expire token ``` **Endpoint** @@ -107,7 +112,44 @@ usedAt = This prevents the same verification token from being successfully used again. ---- +#### Get user's details + +**Endpoint** + +``` +GET: http://localhost:3070/api/profile +``` + +**Respond** + +```json +{ + "success": true, + "message": "User profile retrieved successfully", + "data": { + "user": { + "id": "usr_572gtlpi", + "firstName": "Isuru", + "lastName": "Bimsara", + "email": "ibimsara00@gmail.com", + "accountType": "customer", + "accountStatus": "ACTIVE", + "emailVerifiedAt": "2026-08-20T16:26:04.000Z", + "passwordChangedAt": "2026-08-21T05:29:16.000Z", + "createdAt": "2026-08-20T16:25:30.000Z", + "updatedAt": "2026-08-21T05:29:16.000Z" + }, + "accountDetails": { + "customer_id": "cust_c8avqwbc", + "user_id": "usr_572gtlpi", + "address": "Colombo, Sri Lanka", + "phoneNumber": "0771234567", + "createdAt": "2026-08-20T16:25:31.000Z", + "updatedAt": "2026-08-20T16:25:31.000Z" + } + } +} +``` #### Get All Users diff --git a/app/controllers/auth.controller.js b/app/controllers/auth.controller.js index da10770..4780f89 100644 --- a/app/controllers/auth.controller.js +++ b/app/controllers/auth.controller.js @@ -9,15 +9,35 @@ // app/controllers/auth.controller.js -const { checkPassword } = require("../utils/hashPassword.util"); +const { checkPassword, hashPassword } = require("../utils/hashPassword.util"); const { sendMail } = require("../utils/mail.util"); +const { + validatePassword, +} = require("../utils/validation/validatePassword.util"); +const { validateEmail } = require("../utils/validation/validateEmail.util"); const { generateOTP, validateOTP } = require("../utils/otp.util"); -const {getCachedUser,clearUserCache} = require("../utils/cache.util"); +const { getCachedUser, clearUserCache } = require("../utils/cache.util"); const { generateToken } = require("../utils/jwt.util"); +const { + createRefreshSession, + validateRefreshSession, + deleteRefreshSession, + deleteAllUserSessions, +} = require("../utils/refreshSession.util"); +const { + createPasswordReset, + verifyPasswordResetToken, + deletePasswordReset, + sendPasswordResetEmail, + sendPasswordChangedEmail, +} = require("../utils/passwordReset.utill"); +const db = require("../models"); const { log } = require("../utils/consoleLog.utill"); const appName = process.env.APP_NAME || "Niolla"; +const User = db.User; + // Login Step 1: Request OTP exports.loginReq = async (req, res) => { try { @@ -47,7 +67,7 @@ exports.loginReq = async (req, res) => { firstName: user.firstName, otp: otp, }, - text: `Hello ${user.firstName}, your otp is ${otp}`, + text: `Hello ${user.firstName}, your otp is ${otp}`, }); log(`OTP for ${email}: ${otp}`); @@ -65,6 +85,12 @@ exports.login = async (req, res) => { try { const { email, otp } = req.body; + if (!email || !otp) { + return res + .status(400) + .send({ success: false, message: "Email and OTP are required" }); + } + const user = await getCachedUser(email); if (!user) { @@ -73,7 +99,14 @@ exports.login = async (req, res) => { .send({ success: false, message: "User Not Found" }); } - const isValidOTP = validateOTP(email, otp); + if (user.accountStatus !== "ACTIVE") { + return res.status(403).send({ + success: false, + message: "Account is not active", + }); + } + + const isValidOTP = validateOTP(email, String(otp)); if (!isValidOTP) { return res @@ -87,16 +120,24 @@ exports.login = async (req, res) => { firstName: user.firstName, lastName: user.lastName, email: user.email, - role: user.role, accountType: user.accountType, }); + const { refreshToken } = createRefreshSession(user.id); + // 3. Set JWT as HttpOnly cookie res.cookie("access_token", token, { httpOnly: true, // JS cannot access secure: process.env.NODE_ENV === "production", // HTTPS only in prod sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - maxAge: 24 * 60 * 60 * 1000, // 1 day + maxAge: 15 * 60 * 1000, // 1 day + }); + + res.cookie("refresh_token", refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); log(`JWT issued for ${email}`); @@ -112,6 +153,7 @@ exports.login = async (req, res) => { lastName: user.lastName, role: user.role, accountType: user.accountType, + accessToken: token, }, }); } catch (error) { @@ -120,13 +162,375 @@ exports.login = async (req, res) => { } }; -// Logout: Clear the JWT cookie -exports.logout = (req, res) => { - res.clearCookie("access_token", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - }); +exports.refreshToken = async (req, res) => { + try { + const refreshToken = req.cookies?.refresh_token; - res.json({ success: true, message: "Logged out successfully" }); + if (!refreshToken) { + return res.status(401).send({ + success: false, + message: "Refresh token is required", + }); + } + + const session = validateRefreshSession(refreshToken); + + if (!session) { + res.clearCookie("refresh_token"); + + return res.status(401).send({ + success: false, + message: "Invalid or expired session. Please login again.", + }); + } + + const user = await User.findByPk(session.userId); + + if (!user || user.accountStatus !== "ACTIVE") { + deleteRefreshSession(session.sessionId); + + return res.status(401).send({ + success: false, + message: "Session is no longer valid", + }); + } + + // Generate new Access Token + const token = generateToken({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + accountType: user.accountType, + }); + + // Generate new Refresh Token + const { refreshToken: newRefreshToken } = createRefreshSession(user.id); + + deleteRefreshSession(session.sessionId); + + // Replace access cookie + res.cookie("access_token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + maxAge: 15 * 60 * 1000, + }); + + // Replace refresh cookie + res.cookie("refresh_token", newRefreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + return res.status(200).send({ + success: true, + message: "Session refreshed successfully", + }); + } catch (error) { + console.error("REFRESH ERROR:", error); + + return res.status(401).send({ + success: false, + message: "Invalid or expired session. Please login again.", + }); + } +}; + +exports.forgotPassword = async (req, res) => { + try { + let { email } = req.body; + + if (!email) { + return res.status(400).send({ + success: false, + message: "Email is required", + }); + } + + email = email.trim().toLowerCase(); + + if (!validateEmail(email)) { + return res.status(400).send({ + success: false, + message: "Invalid email address", + }); + } + + const user = await User.findOne({ + where: { email }, + }); + + if (!user) { + return res.status(200).send({ + success: true, + message: + "If an account exists for this email, a password reset link has been sent.", + }); + } + + const resetToken = await createPasswordReset(user.id); + + await sendPasswordResetEmail(user.email, user.firstName, resetToken); + + return res.status(200).send({ + success: true, + message: + "If an account exists for this email, a password reset link has been sent.", + }); + } catch (error) { + console.error("FORGOT PASSWORD ERROR:", error); + + return res.status(500).send({ + success: false, + message: "Unable to process password reset request", + }); + } +}; + +exports.resetPassword = async (req, res) => { + try { + const { token, newPassword, confirmPassword } = req.body; + + if (!token || !newPassword || !confirmPassword) { + return res.status(400).send({ + success: false, + message: "Token, new password and confirm password are required", + }); + } + + if (newPassword !== confirmPassword) { + return res.status(400).send({ + success: false, + message: "Passwords do not match", + }); + } + + if (!validatePassword(newPassword)) { + return res.status(400).send({ + success: false, + message: "Password does not meet the required criteria", + }); + } + + const verification = await verifyPasswordResetToken(token); + + if (!verification) { + return res.status(400).send({ + success: false, + message: "Reset token is invalid or expired", + }); + } + + const { userId, redisKey } = verification; + + const user = await User.findByPk(userId); + + if (!user) { + await deletePasswordReset(redisKey); + + return res.status(400).send({ + success: false, + message: "Reset token is invalid or expired", + }); + } + + const samePassword = await checkPassword(newPassword, user.password); + + if (samePassword) { + return res.status(400).send({ + success: false, + message: "New password must be different from the current password", + }); + } + + const hashedPassword = await hashPassword(newPassword); + + user.password = hashedPassword; + + user.passwordChangedAt = new Date(); + + await user.save(); + + await sendPasswordChangedEmail(user.email, user.firstName); + + await deletePasswordReset(redisKey); + + deleteAllUserSessions(user.id); + + return res.status(200).send({ + success: true, + message: "Password reset successfully. Please login again.", + }); + } catch (error) { + console.error("RESET PASSWORD ERROR:", error); + + return res.status(500).send({ + success: false, + message: "Failed to reset password", + }); + } +}; + +exports.changePassword = async (req, res) => { + try { + // User ID comes from authenticate middleware + const userId = req.user.id; + + const { currentPassword, newPassword, confirmPassword } = req.body; + + // 1. Check required fields + if (!currentPassword || !newPassword || !confirmPassword) { + return res.status(400).send({ + success: false, + message: + "Current password, new password and confirm password are required", + }); + } + + // 2. Check new password and confirmation + if (newPassword !== confirmPassword) { + return res.status(400).send({ + success: false, + message: "New password and confirm password do not match", + }); + } + + // 3. Validate password policy + const passwordValid = validatePassword(newPassword); + + if (!passwordValid) { + return res.status(400).send({ + success: false, + message: "New password does not meet the required criteria", + }); + } + + // 4. Get logged-in user from database + const user = await User.findByPk(userId); + + if (!user) { + return res.status(404).send({ + success: false, + message: "User not found", + }); + } + + // 5. Check current password + const currentPasswordValid = await checkPassword( + currentPassword, + user.password, + ); + + if (!currentPasswordValid) { + return res.status(401).send({ + success: false, + message: "Current password is incorrect", + }); + } + + // 6. Make sure new password is different + const sameAsOldPassword = await checkPassword(newPassword, user.password); + + if (sameAsOldPassword) { + return res.status(400).send({ + success: false, + message: "New password must be different from current password", + }); + } + + // 7. Hash new password + const hashedPassword = await hashPassword(newPassword); + + // 8. Update user + user.password = hashedPassword; + user.passwordChangedAt = new Date(); + + await user.save(); + + // 9. Revoke all refresh sessions + deleteAllUserSessions(user.id); + + // 10. Clear auth cookies + res.clearCookie("access_token", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + }); + + res.clearCookie("refresh_token", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + }); + + // 11. Send confirmation email + try { + await sendPasswordChangedEmail(user.email, user.firstName); + } catch (mailError) { + console.error("PASSWORD CHANGED EMAIL ERROR:", mailError); + } + + // 12. Response + return res.status(200).send({ + success: true, + message: "Password changed successfully. Please login again.", + }); + } catch (error) { + console.error("CHANGE PASSWORD ERROR:", error); + + return res.status(500).send({ + success: false, + message: "Failed to change password", + }); + } +}; + +// Logout: Clear the JWT cookie +exports.logout = async (req, res) => { + try { + // 1. Get refresh token from cookie + const refreshToken = req.cookies?.refresh_token; + + // 2. If refresh token exists, find its session + if (refreshToken) { + const session = validateRefreshSession(refreshToken); + + // 3. Delete refresh session from server RAM + if (session) { + deleteRefreshSession(session.sessionId); + + console.log(`Refresh session deleted: ${session.sessionId}`); + } + } + + // 4. Clear access token cookie + res.clearCookie("access_token", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + }); + + // 5. Clear refresh token cookie + res.clearCookie("refresh_token", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + }); + + // 6. Send response + return res.status(200).json({ + success: true, + message: "Logged out successfully", + }); + } catch (error) { + console.error("LOGOUT ERROR:", error); + + return res.status(500).json({ + success: false, + message: "Failed to logout", + }); + } }; diff --git a/app/controllers/user.controller.js b/app/controllers/user.controller.js index caeeda1..6a2243b 100644 --- a/app/controllers/user.controller.js +++ b/app/controllers/user.controller.js @@ -9,19 +9,30 @@ // app/controllers/user.controller.js -// const { Op } = require("sequelize"); const db = require("../models"); const { hashPassword } = require("../utils/hashPassword.util"); -const { validatePassword } = require("../utils/validation/validatePassword.util"); +const { + validatePassword, +} = require("../utils/validation/validatePassword.util"); const { validateEmail } = require("../utils/validation/validateEmail.util"); const { generateUserId, generateId } = require("../utils/idGen.util"); +const { + createCustomerDetails, +} = require("../utils/users/createCustomerDetails.util"); +const { + createBusinessCustomerDetails, +} = require("../utils/users/createBusinessCustomer.util"); const { logActivity } = require("../services/activity.service"); const { sendMail } = require("../utils/mail.util"); const { - createEmailVerification, verifyEmailVerificationToken, deleteEmailVerification} = require("../utils/emailVerification.util");; + createEmailVerification, + verifyEmailVerificationToken, + sendVerificationEmail, + deleteEmailVerification, +} = require("../utils/emailVerification.util"); +const { getUserProfile } = require("../utils/users/userProfileDetails.util"); const User = db.User; const Profile = db.Profile; -// const EmailVerification = db.EmailVerification; // Create a new user exports.createNewUser = async (req, res) => { @@ -33,28 +44,48 @@ exports.createNewUser = async (req, res) => { lastName, email, password, + accountType, + address, + phoneNumber, + businessName, + businessRegistrationNumber, + businessType, + contactName, + businessEmail, + expectedMonthlyVolume, + note, } = req.body; - if (!firstName || !lastName || !email || !password) { - await transaction.rollback(); + if (!firstName || !lastName || !email || !password || !accountType) { + await transaction.rollback(); - return res.status(400).send({ - success: false, - message: - "First name, last name, email and password are required", - }); - } + return res.status(400).send({ + success: false, + message: + "First name, last name, email, password and account type are required", + }); + } - const emailValid = validateEmail(email); + if (accountType !== "customer" && accountType !== "business_customer") { + await transaction.rollback(); -if (!emailValid) { - await transaction.rollback(); + return res.status(400).send({ + success: false, + message: + "Invalid account type. Must be either 'customer' or 'business_customer'", + }); + } - return res.status(400).send({ - success: false, - message: "Invalid email address", - }); -} + const emailValid = validateEmail(email); + + if (!emailValid) { + await transaction.rollback(); + + return res.status(400).send({ + success: false, + message: "Invalid email address", + }); + } const userExists = await User.findOne({ where: { email } }); if (userExists) { @@ -66,14 +97,6 @@ if (!emailValid) { }); } - // let pass; - - // if(accountType === "admin" || accountType === "superadmin" || accountType === "manager" || accountType === "support_agent") { - // pass = process.env.DEFAULT_PASSWORD; - // }else{ - // pass = password; - // } - const validatePasswordResult = validatePassword(password); if (!validatePasswordResult) { @@ -95,13 +118,12 @@ if (!emailValid) { lastName, email, password: hashedPassword, - // accountType, + accountType, accountStatus: "PENDING_VERIFICATION", - emailVerifiedAt: null, + emailVerifiedAt: null, }, { transaction }, ); - const newProfile = await Profile.create( { @@ -117,78 +139,52 @@ if (!emailValid) { { transaction }, ); + //create customer and business customer + if (accountType === "customer") { + const customerData = { + address,phoneNumber, + }; + + await createCustomerDetails( + newUser.id, + customerData, + transaction, + ); + } else if (accountType === "business_customer") { + const businessData = { + businessName, + businessRegistrationNumber, + businessType, + contactName, + phoneNumber, + businessEmail, + expectedMonthlyVolume, + note, + }; + + await createBusinessCustomerDetails( + newUser.id, + businessData, + transaction, + ); + } + await transaction.commit(); const verificationToken = await createEmailVerification(newUser.id); - const confirmationLink = - `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; - - try { - - await sendMail({ - to: - email, - - subject: - "Confirm Your ZUMRI Account", - - templateName: - "emailVerification", - - templateVars: { - - customer_name: - firstName, - - confirmation_link: - confirmationLink, - }, - - text: - `Hello ${firstName}, please verify your ZUMRI account using this link: ${confirmationLink}`, - }); - - - } catch (mailError) { - - console.error( - "VERIFICATION EMAIL ERROR:", - mailError + await sendVerificationEmail( + newUser.email, + newUser.firstName, + verificationToken, ); - - - return res.status(201).send({ - success: true, - - message: - "Account created, but verification email could not be sent. Please request a new verification email.", - - data: { - id: - newUser.id, - - firstName: - newUser.firstName, - - lastName: - newUser.lastName, - - email: - newUser.email, - - accountType: - newUser.accountType, - - accountStatus: - newUser.accountStatus, - }, - }); + } catch (error) { + console.error("Error sending verification email:", error); } await logActivity({ - user: req.user, + user: newUser, description: `Created New User with ID: ${newUser.id}`, type: "CREATE_USER", module: "User Management", @@ -202,20 +198,16 @@ if (!emailValid) { firstName: newUser.firstName, lastName: newUser.lastName, email: newUser.email, - // accountType: newUser.accountType, - // role: newUser.role, - // department: newUser.department, + accountType: newUser.accountType, + }, }); } catch (error) { - if (!transaction.finished) { - await transaction.rollback(); - } + if (!transaction.finished) { + await transaction.rollback(); + } - console.error( - "CREATE USER ERROR:", - error - ); + console.error("CREATE USER ERROR:", error); res.status(500).send({ success: false, @@ -226,168 +218,100 @@ if (!emailValid) { }; // Verify customer email -exports.verifyEmail = - async (req, res) => { +exports.verifyEmail = async (req, res) => { + const transaction = await db.sequelize.transaction(); - const transaction = - await db.sequelize.transaction(); + try { + const { token } = req.body; + if (!token) { + await transaction.rollback(); - try { - const { - token, - } = req.body; + return res.status(400).send({ + success: false, - - if (!token) { - - await transaction.rollback(); - - - return res.status(400).send({ - success: - false, - - message: - "Verification token is required", - }); - } - - const verification = - await verifyEmailVerificationToken( - token - ); - - if (!verification) { - - await transaction.rollback(); - - - return res.status(400).send({ - success: - false, - - message: - "Verification token is invalid or expired", - }); - } - - - - const { - userId, - redisKey, - } = - verification; - - const user = - await User.findOne({ - where: { - id: - userId, - }, - - transaction, - }); - - - - if (!user) { - - await transaction.rollback(); - - await deleteEmailVerification( - redisKey - ); - - - return res.status(404).send({ - success: - false, - - message: - "User not found", - }); - } - - if ( - user.accountStatus === - "ACTIVE" && - user.emailVerifiedAt - ) { - - await transaction.rollback(); - - - // Token is no longer needed - await deleteEmailVerification( - redisKey - ); - - - return res.status(400).send({ - success: - false, - - message: - "Email is already verified", - }); - } - - - user.accountStatus = - "ACTIVE"; - - - user.emailVerifiedAt = - new Date(); - - - await user.save({ - transaction, - }); - - await transaction.commit(); - - await deleteEmailVerification( - redisKey - ); - - - - return res.status(200).send({ - success: - true, - - message: - "Email verified successfully. Your account is now active.", - }); - - - } catch (error) { - - if (!transaction.finished) { - - await transaction.rollback(); - - } - - - console.error( - "VERIFY EMAIL ERROR:", - error - ); - - - return res.status(500).send({ - success: - false, - - message: - "Failed to verify email", + message: "Verification token is required", }); } - }; + + const verification = await verifyEmailVerificationToken(token); + + if (!verification) { + await transaction.rollback(); + + return res.status(400).send({ + success: false, + + message: "Verification token is invalid or expired", + }); + } + + const { userId, redisKey } = verification; + + const user = await User.findOne({ + where: { + id: userId, + }, + + transaction, + }); + + if (!user) { + await transaction.rollback(); + + await deleteEmailVerification(redisKey); + + return res.status(404).send({ + success: false, + + message: "User not found", + }); + } + + if (user.accountStatus === "ACTIVE" && user.emailVerifiedAt) { + await transaction.rollback(); + + // Token is no longer needed + await deleteEmailVerification(redisKey); + + return res.status(400).send({ + success: false, + + message: "Email is already verified", + }); + } + + user.accountStatus = "ACTIVE"; + + user.emailVerifiedAt = new Date(); + + await user.save({ + transaction, + }); + + await transaction.commit(); + + await deleteEmailVerification(redisKey); + + return res.status(200).send({ + success: true, + + message: "Email verified successfully. Your account is now active.", + }); + } catch (error) { + if (!transaction.finished) { + await transaction.rollback(); + } + + console.error("VERIFY EMAIL ERROR:", error); + + return res.status(500).send({ + success: false, + + message: "Failed to verify email", + }); + } +}; // Get users with pagination (20 per page) exports.getAllUsers = async (req, res) => { @@ -422,36 +346,81 @@ exports.getAllUsers = async (req, res) => { } }; -// Get user details by ID -exports.getUserById = async (req, res) => { - try { - const { id } = req.params; - const user = await User.findOne({ - where: { id }, - attributes: { exclude: ["password"] }, - include: [{ model: Profile, as: "profile" }], - }); +//get user profile details +exports.userProfile = async (req, res) => { - if (!user) { + try { + const userId = req.user.id; + + const profile = + await getUserProfile(userId); + + + if (!profile) { return res.status(404).send({ success: false, message: "User not found", }); } - res.status(200).send({ + + return res.status(200).send({ success: true, - data: user, + message: + "User profile retrieved successfully", + + data: profile, }); + + } catch (error) { - res.status(500).send({ + + console.error( + "GET USER PROFILE ERROR:", + error + ); + + + return res.status(500).send({ success: false, - message: "Failed to retrieve user", - error: error.message, + message: + "Failed to retrieve user profile", }); + } + }; +// Get user details by ID +// exports.getUserById = async (req, res) => { +// try { +// const { id } = req.params; +// const user = await User.findOne({ +// where: { id }, +// attributes: { exclude: ["password"] }, +// include: [{ model: Profile, as: "profile" }], +// }); + +// if (!user) { +// return res.status(404).send({ +// success: false, +// message: "User not found", +// }); +// } + +// res.status(200).send({ +// success: true, +// data: user, +// }); +// } catch (error) { +// res.status(500).send({ +// success: false, +// message: "Failed to retrieve user", +// error: error.message, +// }); +// } +// }; + // Update user details exports.updateUser = async (req, res) => { const transaction = await db.sequelize.transaction(); diff --git a/app/models/index.js b/app/models/index.js index d695284..18b07a6 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -41,6 +41,8 @@ db.sequelize = sequelize; // User and Authentication db.User = require("./user/user.model")(sequelize, DataTypes); +db.Customer = require("./user/customer.model")(sequelize, DataTypes); +db.BusinessCustomer = require("./user/businessCustomer.model")(sequelize, DataTypes); db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes); db.Profile = require("./user/profile.model")(sequelize, DataTypes); diff --git a/app/models/user/businessCustomer.model.js b/app/models/user/businessCustomer.model.js new file mode 100644 index 0000000..10e5282 --- /dev/null +++ b/app/models/user/businessCustomer.model.js @@ -0,0 +1,65 @@ +//app/models/user/businessCustomer.model.js + +module.exports = (sequelize, DataTypes) => { + const BusinessCustomer = sequelize.define( + "BusinessCustomer", + { + business_customer_id: { + type: DataTypes.STRING, + primaryKey: true, + }, + + user_id: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + + businessName: { + type: DataTypes.STRING, + allowNull: false, + }, + businessRegistrationNumber: { + type: DataTypes.STRING, + allowNull: false, + }, + businessType: { + type: DataTypes.STRING, + allowNull: false, + }, + contactName: { + type: DataTypes.STRING, + allowNull: false, + }, + phoneNumber: { + type: DataTypes.STRING, + allowNull: false, + }, + businessEmail: { + type: DataTypes.STRING, + allowNull: false, + }, + expectedMonthlyVolume: { + type: DataTypes.STRING, + allowNull: false, + }, + note: { + type: DataTypes.STRING, + allowNull: true, + }, + }, + { + tableName: "business_customers", + timestamps: true, + }, + ); + + BusinessCustomer.associate = (db) => { + BusinessCustomer.belongsTo(db.User, { + foreignKey: "user_id", + as: "user", + }); + }; + + return BusinessCustomer; +}; diff --git a/app/models/user/customer.model.js b/app/models/user/customer.model.js new file mode 100644 index 0000000..fc76a5f --- /dev/null +++ b/app/models/user/customer.model.js @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2026 Niolla + * All rights reserved. + * + * This source code is proprietary and confidential. + * Unauthorized copying, modification, distribution, or use + * of this file, via any medium, is strictly prohibited. + */ + +// app/models/customer.model.js + +module.exports = (sequelize, DataTypes) => { + const Customer = sequelize.define( + "Customer", + { + customer_id: { + type: DataTypes.STRING, + primaryKey: true, + collate: "utf8mb4_general_ci", + }, + user_id: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + address: { + type: DataTypes.STRING, + allowNull: false, + }, + phoneNumber: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: "customers", + timestamps: true, + }, + ); + + Customer.associate = (db) => { + Customer.belongsTo(db.User, { + foreignKey: "user_id", + as: "user", + }); + }; + + return Customer; +}; diff --git a/app/models/user/user.model.js b/app/models/user/user.model.js index e73e011..c147dd3 100644 --- a/app/models/user/user.model.js +++ b/app/models/user/user.model.js @@ -16,62 +16,73 @@ module.exports = (sequelize, DataTypes) => { id: { type: DataTypes.STRING, primaryKey: true, - collate: 'utf8mb4_general_ci' + collate: "utf8mb4_general_ci", }, firstName: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, lastName: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, email: { type: DataTypes.STRING, allowNull: false, - unique: true + unique: true, }, password: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, accountType: { - type: DataTypes.ENUM("admin", "superadmin", "manager", "business_customer", "rider", "customer","support_agent"), - defaultValue: "customer" + type: DataTypes.ENUM("business_customer", "customer"), + defaultValue: "customer", }, accountStatus: { type: DataTypes.ENUM( "PENDING_VERIFICATION", "ACTIVE", "SUSPENDED", - "DEACTIVATED" + "DEACTIVATED", ), allowNull: false, defaultValue: "PENDING_VERIFICATION", }, - emailVerifiedAt: { + emailVerifiedAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + passwordChangedAt: { type: DataTypes.DATE, allowNull: true, defaultValue: null, }, - }, { tableName: "users", - timestamps: true - } + timestamps: true, + }, ); User.associate = (db) => { User.hasMany(db.userPermission, { foreignKey: "user_id", - as: "userPermissions" + as: "userPermissions", }); User.hasOne(db.Profile, { foreignKey: "user_id", as: "profile", }); - + User.hasOne(db.Customer, { + foreignKey: "user_id", + as: "customer", + }); + User.hasOne(db.BusinessCustomer, { + foreignKey: "user_id", + as: "businessCustomer", + }); }; return User; diff --git a/app/routes/auth.routes.js b/app/routes/auth.routes.js index 4d334ad..d22bacd 100644 --- a/app/routes/auth.routes.js +++ b/app/routes/auth.routes.js @@ -24,6 +24,10 @@ router.get("/me", authenticate, (req, res) => { router.post('/req-otp', authController.loginReq); router.post('/login', authController.login); +router.post('/refresh', authController.refreshToken); +router.post('/forgot-password', authController.forgotPassword); +router.post('/reset-password', authController.resetPassword); +router.post('/change-password', authenticate, authController.changePassword); router.post('/logout', authController.logout); module.exports = router; diff --git a/app/routes/user.routes.js b/app/routes/user.routes.js index 67dbb68..4f67710 100644 --- a/app/routes/user.routes.js +++ b/app/routes/user.routes.js @@ -32,6 +32,12 @@ router.post( userController.verifyEmail ); +router.get( + "/profile", + authenticate, + userController.userProfile +); + router.get( "/", authenticate, @@ -39,12 +45,12 @@ router.get( userController.getAllUsers ); -router.get( - "/:id", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - userController.getUserById -); +// router.get( +// "/:id", +// authenticate, +// authorizedAccountType(["admin", "management", "team_head", "user"]), +// userController.getUserById +// ); router.patch( "/:id", diff --git a/app/templates/emails/otp.html b/app/templates/emails/otp.html index f2672dd..b820af8 100644 --- a/app/templates/emails/otp.html +++ b/app/templates/emails/otp.html @@ -2,14 +2,14 @@ - GLAURA 2FA Code + ZUMRI CEYLON

Dear {{firstName}},

-

Greetings from Oceanic Titan!

+

Greetings from ZUMRI CEYLON!

-

Your One Time Password (OTP) to log in to your Oceanic Titan account is mentioned below.

+

Your One Time Password (OTP) to log in to your ZUMRI CEYLON account is mentioned below.

Please enter this OTP in the required field to proceed further:

@@ -24,14 +24,14 @@ -

The above-mentioned OTP is valid for 5 minutes.

+

The above-mentioned OTP is valid for 15 minutes.


Regards,
- Oceanic Titan Team + ZUMRI CEYLON Team

-

{{currentYear}} Oceanic Titan. This is an auto-generated email, please do not reply to this email.

+

{{currentYear}} ZUMRI CEYLON. This is an auto-generated email, please do not reply to this email.

\ No newline at end of file diff --git a/app/templates/emails/passwordChanged.html b/app/templates/emails/passwordChanged.html new file mode 100644 index 0000000..f7b4b0d --- /dev/null +++ b/app/templates/emails/passwordChanged.html @@ -0,0 +1,32 @@ + + + + + Your ZUMRI password was changed + + + + +

Hi {{customer_name}},

+ +

+ Your password was changed at {{changed_at}}. +

+ +

+ If this was not you, contact support immediately. +

+ +
+ +

+ Thank you,
+ ZUMRI Team +

+ +

+ Document Classification: Internal Use Only, Version: 1.0 +

+ + + \ No newline at end of file diff --git a/app/templates/emails/passwordReset.html b/app/templates/emails/passwordReset.html index 61c62e8..5884f70 100644 --- a/app/templates/emails/passwordReset.html +++ b/app/templates/emails/passwordReset.html @@ -1,53 +1,242 @@ - - - Document - + + + + + Reset Your ZUMRI Password - -

Dear {{firstName}},

-

Greetings from Oceanic Titan!

+ -

You have requested to reset your password for your Oceanic Titan account. Please click the link below to reset your password:

+ + + + +
-

Reset Password

+ -

If you did not request a password reset, please ignore this email. The above link will expire in {{expiryTime}}.

+ + + + -
-

Regards,
- Oceanic Titan Team -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ Hi {{firstName}}, +

+
+

+ Reset Your ZUMRI Password +

+
+

+ We received a request to reset the password + for your ZUMRI account. + Click the button below to create a new password. +

+
+ + + Reset Password + + +
+

+ If the button doesn't work, copy and paste + the following link into your browser: +

+ +

+ + {{resetLink}} + +

+
+

+ For security purposes, this password reset + link will expire in + {{expiryTime}}. +

+
+

+ If you did not request a password reset, + you can safely ignore this email. + Your password will remain unchanged. +

+
+

+ Best regards,
+ ZUMRI Team +

+
+

+ © {{currentYear}} ZUMRI. + All rights reserved. +

+
+ +
-

{{currentYear}} Oceanic Titan. This is an auto-generated email, please do not reply to this email.

\ No newline at end of file diff --git a/app/utils/emailVerification.util.js b/app/utils/emailVerification.util.js index 2ab997d..d7e26af 100644 --- a/app/utils/emailVerification.util.js +++ b/app/utils/emailVerification.util.js @@ -1,103 +1,91 @@ // app/utils/emailVerification.util.js const crypto = require("crypto"); - +const { sendMail } = require("../utils/mail.util"); const redis = require("../config/redisClient"); const EMAIL_VERIFICATION_TTL = - Number( - process.env.EMAIL_VERIFICATION_TTL_SECONDS - ) || 1800; + Number(process.env.EMAIL_VERIFICATION_TTL_SECONDS) || 1800; const generateEmailVerificationToken = () => { - return crypto - .randomBytes(32) - .toString("hex"); + return crypto.randomBytes(32).toString("hex"); }; const hashEmailVerificationToken = (token) => { - return crypto - .createHash("sha256") - .update(token) - .digest("hex"); + return crypto.createHash("sha256").update(token).digest("hex"); }; const createEmailVerification = async (userId) => { - // 1. Generate raw token - const token = - generateEmailVerificationToken(); - + const token = generateEmailVerificationToken(); // 2. Hash token - const tokenHash = - hashEmailVerificationToken(token); - + const tokenHash = hashEmailVerificationToken(token); // 3. Create Redis key - const redisKey = - `email-verification:${tokenHash}`; + const redisKey = `email-verification:${tokenHash}`; - await redis.set( - redisKey, - userId, - "EX", - EMAIL_VERIFICATION_TTL - ); + await redis.set(redisKey, userId, "EX", EMAIL_VERIFICATION_TTL); return token; }; - /** * Check a verification token. */ -const verifyEmailVerificationToken = - async (token) => { +const verifyEmailVerificationToken = async (token) => { + if (!token || typeof token !== "string") { + return null; + } - if ( - !token || - typeof token !== "string" - ) { - return null; - } + // 1. Hash token received from user + const tokenHash = hashEmailVerificationToken(token); + // 2. Build same Redis key + const redisKey = `email-verification:${tokenHash}`; - // 1. Hash token received from user - const tokenHash = - hashEmailVerificationToken(token); + // 3. Search Redis + const userId = await redis.get(redisKey); + if (!userId) { + return null; + } - // 2. Build same Redis key - const redisKey = - `email-verification:${tokenHash}`; - - - // 3. Search Redis - const userId = - await redis.get(redisKey); - - if (!userId) { - return null; - } - - - return { - userId, - redisKey, - }; + return { + userId, + redisKey, }; +}; -const deleteEmailVerification = - async (redisKey) => { +//send verification email to user +const sendVerificationEmail = async (email, firstName, verificationToken) => { + const confirmationLink = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; - await redis.del(redisKey); - }; + // Send email + await sendMail({ + to: email, + subject: "Confirm Your ZUMRI Account", + + templateName: "emailVerification", + + templateVars: { + customer_name: firstName, + confirmation_link: confirmationLink, + }, + + text: `Hello ${firstName}, please verify your ZUMRI account using this link: ${confirmationLink}`, + }); +}; + +const deleteEmailVerification = async (redisKey) => { + await redis.del(redisKey); +}; module.exports = { createEmailVerification, verifyEmailVerificationToken, + sendVerificationEmail, deleteEmailVerification, hashEmailVerificationToken, -}; \ No newline at end of file +}; diff --git a/app/utils/idGen.util.js b/app/utils/idGen.util.js index 5d9d7f2..0fc0abc 100644 --- a/app/utils/idGen.util.js +++ b/app/utils/idGen.util.js @@ -23,6 +23,14 @@ const generateUserId = () => { return "usr_" + Math.random().toString(36).slice(2, 10); }; +const generateCustomerId = () => { + return "cust_" + Math.random().toString(36).slice(2, 10); +} + +const generateBusinessCustomerId = () => { + return "b_cust_" + Math.random().toString(36).slice(2, 10); +} + const generateClientId = () => { const prefix = "cli_"; return prefix + uuidv4(); @@ -141,6 +149,8 @@ const generateDocumentReferenceNo = async (type) => { module.exports = { generateUserId, + generateCustomerId, + generateBusinessCustomerId, generateClientId, generateInquId, generateInquRefNo, diff --git a/app/utils/jwt.util.js b/app/utils/jwt.util.js index f2bb54a..f612f8a 100644 --- a/app/utils/jwt.util.js +++ b/app/utils/jwt.util.js @@ -13,7 +13,10 @@ const jwt = require("jsonwebtoken"); require("dotenv").config(); const JWT_SECRET = process.env.JWT_SECRET || "your_jwt_secret_key"; -const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1d"; // token validity +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "15m"; // token validity + +const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || "your_refresh_token_secret_key"; +const REFRESH_TOKEN_DAYS = process.env.REFRESH_TOKEN_DAYS || "7d"; // refresh token validity /** * Generate JWT token @@ -33,4 +36,12 @@ const verifyToken = (token) => { return jwt.verify(token, JWT_SECRET); }; -module.exports = { generateToken, verifyToken }; +const generateRefreshToken = (payload) => { + return jwt.sign(payload, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_DAYS }); +} + +const verifyRefreshToken = (token) => { + return jwt.verify(token, REFRESH_TOKEN_SECRET); +} + +module.exports = { generateToken, verifyToken, generateRefreshToken, verifyRefreshToken }; diff --git a/app/utils/otp.util.js b/app/utils/otp.util.js index 7c391d8..8259d2b 100644 --- a/app/utils/otp.util.js +++ b/app/utils/otp.util.js @@ -17,8 +17,9 @@ function generateOTP(key){ return otp; } -function saveOTP(key, otp, ttl = 5 * 60 * 1000) { // default TTL: 5 mins - const expiresAt = Date.now() + ttl; +function saveOTP(key, otp) { + ttl = parseInt(process.env.LOGIN_OTP_TTL_SECONDS || 300); + const expiresAt = Date.now() + ttl * 1000; // Convert seconds to milliseconds otpCache.set(key, { otp, expiresAt }); } diff --git a/app/utils/passwordReset.utill.js b/app/utils/passwordReset.utill.js index fd6ea95..aca6e06 100644 --- a/app/utils/passwordReset.utill.js +++ b/app/utils/passwordReset.utill.js @@ -10,104 +10,127 @@ // app/utils/passwordReset.util.js const crypto = require("crypto"); +const redis = require("../config/redisClient"); +const { sendMail } = require("./mail.util"); -const createRedisConnection = require("../config/redis.config"); -const redis = createRedisConnection(); +const PASSWORD_RESET_TTL = + Number(process.env.PASSWORD_RESET_TTL_SECONDS) || 900; -const db = require("../models"); +const generatePasswordResetToken = () => { + return crypto.randomBytes(32).toString("hex"); +}; -const User = db.User; +const hashPasswordResetToken = (token) => { + return crypto.createHash("sha256").update(token).digest("hex"); +}; -const { log } = require("./consoleLog.utill"); -const { hashPassword } = require("./hashPassword.util"); +const createPasswordReset = async (userId) => { + // 1. Generate RAW token + const token = generatePasswordResetToken(); -const RESET_TOKEN_TTL = process.env.RESET_TOKEN_TTL || 10 * 60; // 10 minutes + // 2. Hash RAW token + const tokenHash = hashPasswordResetToken(token); -/** - * Generate password reset token - * - * @param {string} userId - * @returns {Promise} - */ -async function generateResetToken(userId) { - try { - const token = crypto.randomBytes(32).toString("hex"); + // 3. Create Redis key + const redisKey = `password-reset:${tokenHash}`; - const tokenHash = crypto - .createHash("sha256") - .update(token) - .digest("hex"); + // 4. Save user ID with 15 minute TTL + await redis.set(redisKey, userId, "EX", PASSWORD_RESET_TTL); - await redis.set( - `passwordReset:user:${userId}`, - tokenHash, - "EX", - RESET_TOKEN_TTL - ); - log(`Generated password reset token for user ${userId} with TTL of ${RESET_TOKEN_TTL} seconds, Generated token:`, token); - return token; - } catch (error) { - log("Password reset token generation failed", error); - throw new Error("Failed to generate password reset token"); + // Send only RAW token to user + return token; +}; + +const verifyPasswordResetToken = async (token) => { + if (!token || typeof token !== "string") { + return null; } -} -/** - * Reset password using token - * - * @param {string} userId - * @param {string} token - * @param {string} newPassword - */ -async function resetPassword(userId, token, newPassword) { - try { - const storedHash = await redis.get( - `passwordReset:user:${userId}` - ); + // Hash token received from user + const tokenHash = hashPasswordResetToken(token); - if (!storedHash) { - throw new Error("Invalid or expired reset token"); - } + // Create same Redis key + const redisKey = `password-reset:${tokenHash}`; - const tokenHash = crypto - .createHash("sha256") - .update(token) - .digest("hex"); + // Search Redis + const userId = await redis.get(redisKey); - const isValid = - crypto.timingSafeEqual( - Buffer.from(storedHash), - Buffer.from(tokenHash) - ); - - if (!isValid) { - throw new Error("Invalid or expired reset token"); - } - - const user = await User.findByPk(userId); - - if (!user) { - throw new Error("User not found"); - } - - user.password = await hashPassword(newPassword); - - await user.save(); - - await redis.del(`passwordReset:user:${userId}`); - - log( - `Password reset completed successfully for user ${userId}` - ); - - return true; - } catch (error) { - log("Password reset failed", error); - throw error; + if (!userId) { + return null; } -} + + return { + userId, + redisKey, + }; +}; + +const deletePasswordReset = async (redisKey) => { + await redis.del(redisKey); +}; + +const sendPasswordResetEmail = + async (email, firstName, resetToken) => { + + const resetLink = + `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; + + + await sendMail({ + to: email, + + subject: + "Reset your ZUMRI password", + + templateName: + "passwordReset", + + templateVars: { + firstName: firstName, + resetLink: resetLink, + expiryTime: "15 minutes", + }, + + text: + `Hi ${firstName}, use this link within 15 minutes to reset your password: ${resetLink}`, + }); + }; + + const sendPasswordChangedEmail = async ( + email, + firstName +) => { + + const changedAt = + new Date().toLocaleString(); + + await sendMail({ + to: email, + + subject: + "Your ZUMRI password was changed", + + templateName: + "passwordChanged", + + templateVars: { + customer_name: + firstName, + + changed_at: + changedAt, + }, + + text: + `Hi ${firstName}, your password was changed at ${changedAt}. If this was not you, contact support immediately.`, + }); +}; module.exports = { - generateResetToken, - resetPassword + createPasswordReset, + verifyPasswordResetToken, + deletePasswordReset, + hashPasswordResetToken, + sendPasswordResetEmail, + sendPasswordChangedEmail, }; diff --git a/app/utils/refreshSession.util.js b/app/utils/refreshSession.util.js new file mode 100644 index 0000000..bef329a --- /dev/null +++ b/app/utils/refreshSession.util.js @@ -0,0 +1,201 @@ +// app/utils/refreshSession.util.js + +const crypto = require("crypto"); + +const { + generateRefreshToken, + verifyRefreshToken, +} = require("./jwt.util"); + +const refreshSessions = new Map(); + + +// Default = 7 days +const REFRESH_SESSION_TTL = + 7 * 24 * 60 * 60 * 1000; + + +const hashRefreshToken = (token) => { + return crypto + .createHash("sha256") + .update(token) + .digest("hex"); +}; + +const createRefreshSession = (userId) => { + + // Unique session ID + const sessionId = + crypto.randomUUID(); + + + // Create raw Refresh JWT + const refreshToken = + generateRefreshToken({ + sub: userId, + sid: sessionId, + }); + + + // Hash raw refresh token + const tokenHash = + hashRefreshToken( + refreshToken + ); + + + // Expiration time + const expiresAt = + Date.now() + + REFRESH_SESSION_TTL; + + + // Save only HASH in RAM + refreshSessions.set( + sessionId, + { + userId, + tokenHash, + expiresAt, + } + ); + + + return { + refreshToken, + sessionId, + }; +}; + +const validateRefreshSession = ( + refreshToken +) => { + + if (!refreshToken) { + return null; + } + + + let decoded; + + try { + + decoded = + verifyRefreshToken( + refreshToken + ); + + } catch (error) { + + return null; + + } + + + const sessionId = + decoded.sid; + + const userId = + decoded.sub; + + + if (!sessionId || !userId) { + return null; + } + + + // Get session from RAM + const session = + refreshSessions.get( + sessionId + ); + + + if (!session) { + return null; + } + + + // Check session expiration + if ( + Date.now() > + session.expiresAt + ) { + + refreshSessions.delete( + sessionId + ); + + return null; + } + + + // Hash received refresh token + const receivedHash = + hashRefreshToken( + refreshToken + ); + + + // Compare stored hash + if ( + receivedHash !== + session.tokenHash + ) { + return null; + } + + + // Extra user check + if ( + session.userId !== + userId + ) { + return null; + } + + + return { + userId, + sessionId, + }; +}; + +const deleteRefreshSession = ( + sessionId +) => { + + refreshSessions.delete( + sessionId + ); +}; + +const deleteAllUserSessions = ( + userId +) => { + + for ( + const [sessionId, session] + of refreshSessions.entries() + ) { + + if ( + session.userId === userId + ) { + + refreshSessions.delete( + sessionId + ); + + } + + } +}; + + +module.exports = { + createRefreshSession, + validateRefreshSession, + deleteRefreshSession, + deleteAllUserSessions, +}; \ No newline at end of file diff --git a/app/utils/users/createBusinessCustomer.util.js b/app/utils/users/createBusinessCustomer.util.js new file mode 100644 index 0000000..1f55a2a --- /dev/null +++ b/app/utils/users/createBusinessCustomer.util.js @@ -0,0 +1,86 @@ +// app/utils/users/createBusinessCustomer.util.js + +const db = require("../../models"); + +const { + generateBusinessCustomerId, +} = require("../idGen.util"); + +const BusinessCustomer = db.BusinessCustomer; + + +/** + * Create business-customer-specific details + */ +const createBusinessCustomerDetails = async ( + userId, + businessData, + transaction +) => { + + const { + businessName, + businessRegistrationNumber, + businessType, + contactName, + phoneNumber, + businessEmail, + expectedMonthlyVolume, + note, + } = businessData; + + + if ( + !businessName || + !businessRegistrationNumber || + !businessType || + !contactName || + !phoneNumber || + !businessEmail || + !expectedMonthlyVolume + ) { + throw new Error( + "Required business customer details are missing" + ); + } + + + const businessCustomer = + await BusinessCustomer.create( + { + business_customer_id: + generateBusinessCustomerId(), + + user_id: + userId, + + businessName, + + businessRegistrationNumber, + + businessType, + + contactName, + + phoneNumber, + + businessEmail, + + expectedMonthlyVolume, + + note: + note || null, + }, + { + transaction, + } + ); + + + return businessCustomer; +}; + + +module.exports = { + createBusinessCustomerDetails, +}; \ No newline at end of file diff --git a/app/utils/users/createCustomerDetails.util.js b/app/utils/users/createCustomerDetails.util.js new file mode 100644 index 0000000..81b4110 --- /dev/null +++ b/app/utils/users/createCustomerDetails.util.js @@ -0,0 +1,54 @@ +//app/utils/users/createCustomerDetails.util.js + +const db = require("../../models"); + +const { generateCustomerId } = require("../idGen.util"); + +const Customer = db.Customer; + +const createCustomerDetails = async ( + userId, + customerData, + transaction +) => { + + const { + address, + phoneNumber, + } = customerData; + + + if (!address || !phoneNumber) { + throw new Error( + "Address and phone number are required for customer" + ); + } + + + const customer = await Customer.create( + { + customer_id: + generateCustomerId(), + + user_id: + userId, + + address: + address, + + phoneNumber: + phoneNumber, + }, + { + transaction, + } + ); + + + return customer; +}; + + +module.exports = { + createCustomerDetails, +}; diff --git a/app/utils/users/userProfileDetails.util.js b/app/utils/users/userProfileDetails.util.js new file mode 100644 index 0000000..de41be7 --- /dev/null +++ b/app/utils/users/userProfileDetails.util.js @@ -0,0 +1,56 @@ +// app/services/userProfile.service.js + +const db = require("../../models"); + +const User = db.User; +const Customer = db.Customer; +const BusinessCustomer = db.BusinessCustomer; + + +const getUserProfile = async (userId) => { + + const user = await User.findByPk(userId, { + attributes: { + exclude: ["password"], + }, + }); + + + if (!user) { + return null; + } + + + let accountDetails = null; + + if (user.accountType === "customer") { + + accountDetails = await Customer.findOne({ + where: { + user_id: user.id, + }, + }); + + } + + else if (user.accountType === "business_customer") { + + accountDetails = + await BusinessCustomer.findOne({ + where: { + user_id: user.id, + }, + }); + + } + + return { + user, + accountDetails, + }; +}; + + +module.exports = { + getUserProfile, +}; \ No newline at end of file