Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions src/services/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -560,10 +560,6 @@ module.exports = class AccountHelper {
}

const result = { access_token: accessToken, refresh_token: refreshToken, user }
const templateData = await notificationTemplateQueries.findOneEmailTemplate(
process.env.REGISTRATION_EMAIL_TEMPLATE_CODE,
user.organization_id
)

if (plaintextEmailId) {
notificationUtils.sendEmailNotification({
Expand Down
199 changes: 122 additions & 77 deletions src/services/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// Third-party libraries
const _ = require('lodash')
const { Op } = require('sequelize')
const bcryptJs = require('bcryptjs')

// Constants and generics
const common = require('@constants/common')
Expand Down Expand Up @@ -232,103 +233,144 @@ module.exports = class AdminHelper {
}

/**
* login admin user
* @method
* @name login
* @param {Object} bodyData - user login data.
* @param {string} bodyData.email - email.
* @param {string} bodyData.password - email.
* @param {string} deviceInformation - device information.
* @returns {JSON} - returns login response
* Handles the login process for admin users.
*
* This method validates user credentials and enforces additional checks
* (such as admin role verification and active session limits) before
* generating access/refresh tokens and creating a user session.
*
* Steps performed:
* 1. Normalize and validate the identifier (email, phone, or username).
* 2. Construct the user lookup query using the DEFAULT_TENANT_CODE.
* 3. Retrieve the user with their associated organizations and roles.
* 4. Verify the user's password using bcrypt.
* 5. Ensure the user has the required admin role (`common.ADMIN_ROLE`).
* 6. Enforce allowed active session limits (if configured).
* 7. Create a new user session record.
* 8. Enrich token payload with user and organization details.
* 9. Generate access and refresh tokens.
* 10. Remove sensitive data and process user image URLs.
* 11. Update session details in Redis for active tracking.
* 12. Return tokens and user details in the success response.
*
* @async
* @param {Object} bodyData - Login request payload.
* @param {string} [bodyData.identifier] - The user's email, phone, or username.
* @param {string} [bodyData.email] - The user's email (alternative to `identifier`).
* @param {string} [bodyData.phone_code] - Phone country code (required if using phone login).
* @param {string} bodyData.password - The user's password.
* @param {Object} deviceInformation - Metadata about the device used for login.
*
* @returns {Promise<Object>} A success response containing:
* - `access_token` {string} - JWT for API access.
* - `refresh_token` {string} - JWT for refreshing sessions.
* - `user` {Object} - Sanitized user object with organization details.
*
* @throws {Error} Rethrows unexpected errors for global error handling.
*
* @example
* const loginResponse = await AuthService.login(
* { identifier: 'admin@example.com', password: 'StrongPass123!' },
* { ip: '192.168.1.1', device: 'Chrome on Windows' }
* );
* console.log(loginResponse.result.access_token);
*/

static async login(bodyData, deviceInformation) {
try {
const plaintextEmailId = bodyData.email.toLowerCase()
const encryptedEmailId = emailEncryption.encrypt(plaintextEmailId)
const userCredentials = await UserCredentialQueries.findOne({ email: encryptedEmailId })
if (!userCredentials) {
return responses.failureResponse({
message: 'USER_DOESNOT_EXISTS',
statusCode: httpStatusCode.bad_request,
// helper for consistent failure responses
const failure = (message, status = httpStatusCode.bad_request) =>
responses.failureResponse({
message,
statusCode: status,
responseCode: 'CLIENT_ERROR',
})

// 1) Identifier handling: accept `identifier` or fallback to `email`
const rawIdentifier = (bodyData.identifier || bodyData.email || '').toString().trim()
const identifier = rawIdentifier.toLowerCase()
if (!identifier) return failure('IDENTIFIER_REQUIRED', httpStatusCode.bad_request)

// identifier type helpers
const isEmail = (str) => /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(str)
const isPhone = (str) => /^\+?[1-9]\d{1,14}$/.test(str)
const isUsername = (str) => /^[a-zA-Z0-9_]{3,30}$/.test(str)

// 2) Build query (skip domain checks; use DEFAULT_TENANT_CODE)
const query = {
[Op.or]: [],
password: { [Op.ne]: null },
status: common.ACTIVE_STATUS,
tenant_code: process.env.DEFAULT_TENANT_CODE,
}

let user = await userQueries.findOne({
id: userCredentials.user_id,
// organization_id: userCredentials.organization_id,
})
if (!user.id) {
return responses.failureResponse({
message: 'USER_DOESNOT_EXISTS',
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
if (isEmail(identifier)) {
query[Op.or].push({ email: emailEncryption.encrypt(identifier) })
} else if (isPhone(identifier)) {
// expects bodyData.phone_code when phone login is used
query[Op.or].push({
phone: emailEncryption.encrypt(identifier),
phone_code: bodyData.phone_code,
})
} else {
query[Op.or].push({ username: identifier })
}
const isPasswordCorrect = utils.comparePassword(bodyData.password, user.password)

// 3) Find user (reuse the helper that returns org associations like user/login)
const userInstance = await userQueries.findUserWithOrganization(query, {}, true)
let user = userInstance ? userInstance.toJSON() : null

if (!user) return failure('IDENTIFIER_OR_PASSWORD_INVALID', httpStatusCode.bad_request)

//Password verification (bcrypt async compare)
const isPasswordCorrect = await bcryptJs.compare(bodyData.password, user.password)
if (!isPasswordCorrect) {
return responses.failureResponse({
message: 'PASSWORD_INVALID',
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
return failure('IDENTIFIER_OR_PASSWORD_INVALID', httpStatusCode.bad_request)
}

let roles = await roleQueries.findAll(
{ id: user.roles },
{
attributes: {
exclude: ['created_at', 'updated_at', 'deleted_at'],
},
}
// Check for admin role
const hasAdminRole = user.user_organizations?.some((org) =>
org.roles?.some((r) => r.role?.title?.toLowerCase() === common.ADMIN_ROLE)
)
if (!roles) {
return responses.failureResponse({
message: 'ROLE_NOT_FOUND',
statusCode: httpStatusCode.not_acceptable,
responseCode: 'CLIENT_ERROR',
})

if (!hasAdminRole) {
return failure('IDENTIFIER_OR_PASSWORD_INVALID', httpStatusCode.bad_request)
}
// 4) Active session limit enforcement (if configured)
if (process.env.ALLOWED_ACTIVE_SESSIONS != null) {
const activeSessionCount = await userSessionsService.activeUserSessionCounts(user.id)
if (activeSessionCount >= Number(process.env.ALLOWED_ACTIVE_SESSIONS)) {
return failure('ACTIVE_SESSION_LIMIT_EXCEEDED', httpStatusCode.not_acceptable)
}
}

// create user session entry and add session_id to token data
// 6) Create user session
const userSessionDetails = await userSessionsService.createUserSession(
user.id, // userid
'', // refresh token
'', // Access token
deviceInformation
user.id,
'', // refresh token placeholder
'', // access token placeholder
deviceInformation,
user.tenant_code
)

/**
* Based on user organisation id get user org parent Id value
* If parent org id is present then set it to tenant of user
* if not then set user organisation id to tenant
*/

// let tenantDetails = await organizationQueries.findOne(
// { id: user.organization_id },
// { attributes: ['parent_id'] }
// )
// 7) Token payload enrichment - follow same shape as user/login
// Ensure organizations exist; if not, create a default org object from env

// const tenant_id =
// tenantDetails && tenantDetails.parent_id !== null ? tenantDetails.parent_id : user.organization_id
user = UserTransformDTO.transform(user)

const tokenDetail = {
data: {
id: user.id,
name: user.name,
session_id: userSessionDetails.result.id,
organizations: [
{
id: process.env.DEFAULT_ORG_ID,
roles: roles,
},
],
tenant_code: process.env.DEFAULT_TENANT_CODE,
organization_ids: user.organizations.map((o) => String(o.id)),
organization_codes: user.organizations.map((o) => String(o.code)),
organizations: user.organizations,
tenant_code: user.tenant_code,
},
}

user.user_roles = roles

// 8) Generate tokens (same helper as user/login)
const accessToken = utils.generateToken(
tokenDetail,
process.env.ACCESS_TOKEN_SECRET,
Expand All @@ -340,20 +382,23 @@ module.exports = class AdminHelper {
common.refreshTokenExpiry
)

/**
* This function call will do below things
* 1: create redis entry for the session
* 2: update user-session with token and refresh_token
*/
// 9) Remove sensitive fields and post-process user image
delete user.password
if (user && user.image) {
user.image = await utils.getDownloadableUrl(user.image)
}

// 10) Return original identifier and result
user.identifier = identifier
const result = { access_token: accessToken, refresh_token: refreshToken, user }

// 11) Update session and set Redis data (same flow as user/login)
await userSessionsService.updateUserSessionAndsetRedisData(
userSessionDetails.result.id,
accessToken,
refreshToken
)

delete user.password
const result = { access_token: accessToken, refresh_token: refreshToken, user }

return responses.successResponse({
statusCode: httpStatusCode.ok,
message: 'LOGGED_IN_SUCCESSFULLY',
Expand Down
2 changes: 2 additions & 0 deletions src/services/organization.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ module.exports = class OrganizationsHelper {
domain: domain,
organization_id: createdOrganization.id,
created_by: loggedInUserId,
updated_by: loggedInUserId,
tenant_code: bodyData.tenant_code,
}
await orgDomainQueries.create(domainCreationData)
})
Expand Down
2 changes: 1 addition & 1 deletion src/services/user-sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ module.exports = class UserSessionsHelper {
if (accessToken !== '') {
userSessionDetails.token = accessToken
}
if (accessToken !== '') {
if (refreshToken !== '') {
userSessionDetails.refresh_token = refreshToken
}
Comment on lines +44 to 46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard against null/whitespace refresh tokens before persisting

The new check prevents empty strings, but null/undefined or whitespace strings could still slip through from some call sites. Persist only non-empty, trimmed strings to keep DB clean and consistent.

Apply this diff:

-      if (refreshToken !== '') {
-        userSessionDetails.refresh_token = refreshToken
-      }
+      if (typeof refreshToken === 'string' && refreshToken.trim() !== '') {
+        userSessionDetails.refresh_token = refreshToken.trim()
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (refreshToken !== '') {
userSessionDetails.refresh_token = refreshToken
}
if (typeof refreshToken === 'string' && refreshToken.trim() !== '') {
userSessionDetails.refresh_token = refreshToken.trim()
}
🤖 Prompt for AI Agents
In src/services/user-sessions.js around lines 44 to 46, the current check only
blocks the empty string but allows null/undefined or whitespace-only tokens;
update the guard to first verify refreshToken is not null/undefined, trim it,
and persist only if the trimmed value has length > 0 (e.g., const token = typeof
refreshToken === 'string' ? refreshToken.trim() : ''; if (token)
userSessionDetails.refresh_token = token). This ensures only non-empty, trimmed
strings are saved.


Expand Down