diff --git a/Documentation/User-API.md b/Documentation/User-API.md index 6a62b3c..f57aa43 100644 --- a/Documentation/User-API.md +++ b/Documentation/User-API.md @@ -1,10 +1,20 @@ # User API -#### Create New User +#### Create New Customer Account + +Creates a new customer account and sends an email verification link. + +The account is initially created with: + +```text +accountType = customer +accountStatus = PENDING_VERIFICATION +emailVerifiedAt = null +``` **Endpoint** -``` +```text POST: http://localhost:3070/api/user ``` @@ -12,33 +22,94 @@ POST: http://localhost:3070/api/user ```json { - "firstName": "Jhon", - "lastName": "Doe", - "email": "kalanajayasekara@niolla.lk", - "role": "System Developer", - "accountType": "admin", - "department": "IT Department" + "firstName": "Isuru", + "lastName": "Bimsara", + "email": "ibimsara00@gmail.com", + "password": "Hello@12346" } ``` -**Respond** +**Response** ```json { "success": true, "message": "User Created Successfully", "data": { - "id": "usr_763107d9-b56f-4b81-9d86-1889f98a6e6c", - "firstName": "Jhon", - "lastName": "Doe", - "email": "kalanajayasekara@niolla.lk", - "accountType": "admin", - "role": "System Developer", - "department": "IT Department" + "id": "usr_ei6i4n49", + "firstName": "Isuru", + "lastName": "Bimsara", + "email": "ibimsara00@gmail.com", + "accountType": "customer" } } ``` +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 +``` + +**Endpoint** + +```text +POST: http://localhost:3070/api/user/verify-email +``` + +**Request Body** + +```json +{ + "token": "RAW_VERIFICATION_TOKEN_FROM_EMAIL" +} +``` + +**Successful Response** + +```json +{ + "success": true, + "message": "Email verified successfully. Your account is now active." +} +``` + +After successful verification, the user record changes from: + +```text +accountStatus = PENDING_VERIFICATION +emailVerifiedAt = NULL +``` + +to: + +```text +accountStatus = ACTIVE +emailVerifiedAt = +``` + +The verification record is also updated: + +```text +usedAt = +``` + +This prevents the same verification token from being successfully used again. + +--- + + #### Get All Users **Endpoint** diff --git a/app/config/s3.config.js b/app/config/s3.config.js index e49da4b..b85cad3 100644 --- a/app/config/s3.config.js +++ b/app/config/s3.config.js @@ -9,14 +9,14 @@ // app/config/s3.config.js -const { S3Client } = require("@aws-sdk/client-s3"); +// const { S3Client } = require("@aws-sdk/client-s3"); -const s3 = new S3Client({ - region: process.env.AWS_REGION, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - }, -}); +// const s3 = new S3Client({ +// region: process.env.AWS_REGION, +// credentials: { +// accessKeyId: process.env.AWS_ACCESS_KEY_ID, +// secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, +// }, +// }); -module.exports = s3; \ No newline at end of file +// module.exports = s3; \ No newline at end of file diff --git a/app/controllers/user.controller.js b/app/controllers/user.controller.js index 2005a43..2f5f845 100644 --- a/app/controllers/user.controller.js +++ b/app/controllers/user.controller.js @@ -9,14 +9,18 @@ // 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 { validateEmail } = require("../utils/validation/validateEmail.util"); const { generateUserId, generateId } = require("../utils/idGen.util"); const { logActivity } = require("../services/activity.service"); const { sendMail } = require("../utils/mail.util"); +const {generateEmailVerificationToken, hashEmailVerificationToken} = require("../utils/emailVerification.util"); const User = db.User; const Profile = db.Profile; +const EmailVerification = db.EmailVerification; // Create a new user exports.createNewUser = async (req, res) => { @@ -28,9 +32,29 @@ exports.createNewUser = async (req, res) => { lastName, email, password, - accountType, } = req.body; + if (!firstName || !lastName || !email || !password) { + await transaction.rollback(); + + return res.status(400).send({ + success: false, + message: + "First name, last name, email and password are required", + }); + } + + 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) { await transaction.rollback(); @@ -41,15 +65,15 @@ exports.createNewUser = async (req, res) => { }); } - let pass; + // let pass; - if(accountType === "admin" || accountType === "superadmin" || accountType === "manager" || accountType === "support_agent") { - pass = process.env.DEFAULT_PASSWORD; - }else{ - pass = password; - } + // if(accountType === "admin" || accountType === "superadmin" || accountType === "manager" || accountType === "support_agent") { + // pass = process.env.DEFAULT_PASSWORD; + // }else{ + // pass = password; + // } - const validatePasswordResult = validatePassword(pass); + const validatePasswordResult = validatePassword(password); if (!validatePasswordResult) { await transaction.rollback(); @@ -59,10 +83,21 @@ exports.createNewUser = async (req, res) => { }); } - const hashedPassword = await hashPassword(pass); + const hashedPassword = await hashPassword(password); const userID = generateUserId(); + const verificationToken = generateEmailVerificationToken(); + + const verificationTokenHash = + hashEmailVerificationToken( + verificationToken + ); + + const verificationExpiresAt = + new Date( + Date.now() + + 30 * 60 * 1000 + ); - const newUser = await User.create( { @@ -71,7 +106,9 @@ exports.createNewUser = async (req, res) => { lastName, email, password: hashedPassword, - accountType, + // accountType, + accountStatus: "PENDING_VERIFICATION", + emailVerifiedAt: null, }, { transaction }, ); @@ -91,20 +128,109 @@ exports.createNewUser = async (req, res) => { { transaction }, ); - await sendMail({ - to: email, - subject: "Welcome - Ocenic Titan", - templateName: "welcome", - templateVars: { - firstName: firstName, - email: email, - password: process.env.DEFAULT_PASSWORD, + await EmailVerification.create( + { + id: + generateId(), + + user_id: + newUser.id, + + tokenHash: + verificationTokenHash, + + expiresAt: + verificationExpiresAt, + + usedAt: + null, }, - text: `Hello ${firstName}, your account has been created successfully.`, - }); + { + transaction, + } + ); await transaction.commit(); + const confirmationLink = + `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; + + + // ================================================== + // 18. Send verification email + // ================================================== + + 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) { + + // The database transaction is already committed. + // + // Do NOT delete the user here. + // + // User remains: + // PENDING_VERIFICATION + // + // Later resend-verification can send another email. + + console.error( + "VERIFICATION EMAIL ERROR:", + mailError + ); + + + 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, + }, + }); + } + await logActivity({ user: req.user, description: `Created New User with ID: ${newUser.id}`, @@ -121,8 +247,8 @@ exports.createNewUser = async (req, res) => { lastName: newUser.lastName, email: newUser.email, accountType: newUser.accountType, - role: newUser.role, - department: newUser.department, + // role: newUser.role, + // department: newUser.department, }, }); } catch (error) { @@ -136,6 +262,175 @@ exports.createNewUser = async (req, res) => { } }; +// Verify customer email +exports.verifyEmail = async (req, res) => { + + const transaction = + await db.sequelize.transaction(); + + try { + + // 1. Get token from request body + const { + token + } = req.body; + + + // 2. Token is required + if (!token) { + + await transaction.rollback(); + + return res.status(400).send({ + success: false, + message: + "Verification token is required", + }); + } + + + // 3. Hash the received raw token + const tokenHash = + hashEmailVerificationToken( + token + ); + + + // 4. Find matching valid token + const verification = + await EmailVerification.findOne({ + + where: { + + tokenHash: + tokenHash, + + usedAt: + null, + + expiresAt: { + [Op.gt]: + new Date(), + }, + }, + + transaction, + }); + + + // 5. Token invalid / expired / already used + if (!verification) { + + await transaction.rollback(); + + return res.status(400).send({ + success: false, + message: + "Verification token is invalid or expired", + }); + } + + + // 6. Find user + const user = + await User.findOne({ + + where: { + id: + verification.user_id, + }, + + transaction, + }); + + + // 7. User not found + if (!user) { + + await transaction.rollback(); + + return res.status(404).send({ + success: false, + message: + "User not found", + }); + } + + + // 8. Check already verified + if ( + user.accountStatus === "ACTIVE" && + user.emailVerifiedAt + ) { + + await transaction.rollback(); + + return res.status(400).send({ + success: false, + message: + "Email is already verified", + }); + } + + + // 9. Activate account + user.accountStatus = + "ACTIVE"; + + user.emailVerifiedAt = + new Date(); + + + await user.save({ + transaction, + }); + + + // 10. Mark token as used + verification.usedAt = + new Date(); + + + await verification.save({ + transaction, + }); + + + // 11. Commit + await transaction.commit(); + + + // 12. Success + 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) => { try { diff --git a/app/models/index.js b/app/models/index.js index d695284..eed698c 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -41,6 +41,7 @@ db.sequelize = sequelize; // User and Authentication db.User = require("./user/user.model")(sequelize, DataTypes); +db.EmailVerification = require("./user/emailVerification.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/emailVerification.model.js b/app/models/user/emailVerification.model.js new file mode 100644 index 0000000..a6e8517 --- /dev/null +++ b/app/models/user/emailVerification.model.js @@ -0,0 +1,46 @@ +//app/models/user/emailVerification.model.js +module.exports = (sequelize, DataTypes) => { + const EmailVerification = sequelize.define( + "EmailVerification", + { + id: { + type: DataTypes.STRING, + primaryKey: true, + }, + + user_id: { + type: DataTypes.STRING, + allowNull: false, + }, + + tokenHash: { + type: DataTypes.STRING, + allowNull: false, + }, + + expiresAt: { + type: DataTypes.DATE, + allowNull: false, + }, + + usedAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + }, + { + tableName: "email_verifications", + timestamps: true, + } + ); + + EmailVerification.associate = (db) => { + EmailVerification.belongsTo(db.User, { + foreignKey: "user_id", + as: "user", + }); + }; + + return EmailVerification; +}; \ No newline at end of file diff --git a/app/models/user/user.model.js b/app/models/user/user.model.js index fba021d..4765ff6 100644 --- a/app/models/user/user.model.js +++ b/app/models/user/user.model.js @@ -36,9 +36,25 @@ module.exports = (sequelize, DataTypes) => { allowNull: false }, accountType: { - type: DataTypes.ENUM("user", "admin", "superadmin", "manager", "business_customer", "rider", "customer","support_agent"), - defaultValue: "user" - } + type: DataTypes.ENUM("admin", "superadmin", "manager", "business_customer", "rider", "customer","support_agent"), + defaultValue: "customer" + }, + accountStatus: { + type: DataTypes.ENUM( + "PENDING_VERIFICATION", + "ACTIVE", + "SUSPENDED", + "DEACTIVATED" + ), + allowNull: false, + defaultValue: "PENDING_VERIFICATION", + }, + emailVerifiedAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + }, { tableName: "users", @@ -55,6 +71,11 @@ module.exports = (sequelize, DataTypes) => { foreignKey: "user_id", as: "profile", }); + + User.hasMany(db.EmailVerification, { + foreignKey: "user_id", + as: "emailVerifications", + }); }; return User; diff --git a/app/routes/user.routes.js b/app/routes/user.routes.js index ae9535f..67dbb68 100644 --- a/app/routes/user.routes.js +++ b/app/routes/user.routes.js @@ -22,11 +22,16 @@ const PERMISSIONS = require("../constants/permissions"); router.post( "/", - authenticate, - authorizedAccountType(["admin"]), + // authenticate, + // authorizedAccountType(["admin"]), userController.createNewUser ); +router.post( + "/verify-email", + userController.verifyEmail +); + router.get( "/", authenticate, diff --git a/app/templates/emails/emailVerification.html b/app/templates/emails/emailVerification.html new file mode 100644 index 0000000..4c70832 --- /dev/null +++ b/app/templates/emails/emailVerification.html @@ -0,0 +1,239 @@ + + + + + + + + Confirm Your ZUMRI Account + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ Hi {{customer_name}}, +

+
+

+ Welcome to ZUMRI! +

+
+

+ Your account has been successfully registered. + To complete your registration and activate your + account, please confirm your email address by + clicking the button below. +

+
+ + + Confirm Your Email + + +
+

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

+ +

+ + {{confirmation_link}} + +

+
+

+ For security purposes, this confirmation link + will expire after a limited period. If you did + not create a ZUMRI account, you can safely + ignore this email. +

+
+

+ Thank you for joining ZUMRI. We look forward + to having you with us! +

+
+

+ Best regards,
+ ZUMRI Team +

+
+

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

+
+ +
+ + + \ No newline at end of file diff --git a/app/utils/emailVerification.util.js b/app/utils/emailVerification.util.js new file mode 100644 index 0000000..026e00d --- /dev/null +++ b/app/utils/emailVerification.util.js @@ -0,0 +1,28 @@ +//app/utils/emailVerification.util.js +const crypto = require("crypto"); + +/** + * Generate a cryptographically secure + * random email-verification token. + */ +const generateEmailVerificationToken = () => { + return crypto.randomBytes(32).toString("hex"); +}; + + +/** + * Hash verification token before + * storing it in database. + */ +const hashEmailVerificationToken = (token) => { + return crypto + .createHash("sha256") + .update(token) + .digest("hex"); +}; + + +module.exports = { + generateEmailVerificationToken, + hashEmailVerificationToken, +}; \ No newline at end of file diff --git a/app/utils/validation/validateEmail.util.js b/app/utils/validation/validateEmail.util.js new file mode 100644 index 0000000..bb4a0fe --- /dev/null +++ b/app/utils/validation/validateEmail.util.js @@ -0,0 +1,21 @@ +const validateEmail = (email) => { + // Must be a string + if (typeof email !== "string") { + return false; + } + + // Remove spaces + const trimmedEmail = email.trim(); + + // Basic email format validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + return emailRegex.test(trimmedEmail); +}; +const normalizeEmail = (email) => { + return email.trim().toLowerCase(); +}; + +module.exports = { + validateEmail, normalizeEmail +}; \ No newline at end of file diff --git a/app/utils/validation/validatePassword.util.js b/app/utils/validation/validatePassword.util.js index 51ce82b..4d51b21 100644 --- a/app/utils/validation/validatePassword.util.js +++ b/app/utils/validation/validatePassword.util.js @@ -1,3 +1,4 @@ +//app/utils/validation/validatePassword.util.js const validatePassword = (password) => { // Check if password is a string if (typeof password !== "string") {