GWM : forgot pw , change pw , profile update api's
This commit is contained in:
parent
9526870bd8
commit
dae02a30fd
@ -41,6 +41,10 @@ MAX_FILE_SIZE_MB=5
|
||||
MAX_LOGIN_ATTEMPTS=5
|
||||
LOCKOUT_DURATION_MINUTES=30
|
||||
|
||||
# Frontend URL used in password-reset emails
|
||||
FRONTEND_URL=http://localhost:8080
|
||||
PASSWORD_RESET_EXPIRY_MINUTES=60
|
||||
|
||||
# Bitbucket deploy webhook (POST /deploy?apikey=...)
|
||||
BITBUCKET_API_KEY=
|
||||
EXPECTED_REPO=your-org/your-repo
|
||||
|
||||
@ -61,7 +61,7 @@ Build in this order. Each module: `*.routes.js` → `*.controller.js` → `*.ser
|
||||
|
||||
| # | Module | Status | Notes |
|
||||
|---|--------|--------|-------|
|
||||
| 8 | **auth** | [x] Done | login, refresh, logout, me; rotating hashed refresh tokens; account lockout |
|
||||
| 8 | **auth** | [x] Done | login, refresh, logout, me, profile, avatar, change/forgot/reset password |
|
||||
| 9 | **masters** | [x] Done | 15 sub-masters under `masters/index.js` (UOM template replicated) |
|
||||
| — | **users** | [x] Done | CRUD + summary/filters/export; RBAC: `USERS` |
|
||||
| — | **roles** | [x] Done | CRUD + permission assignment + matrix; RBAC: `ROLES` |
|
||||
@ -118,8 +118,16 @@ Base path: `/api/v1` · Auth: `Authorization: Bearer <accessToken>` (except publ
|
||||
| [x] | POST | `/auth/login` | Public | Returns `accessToken`, `refreshToken` |
|
||||
| [x] | POST | `/auth/refresh` | Public | Rotating refresh token |
|
||||
| [x] | POST | `/auth/logout` | Public | Revoke refresh token |
|
||||
| [ ] | POST | `/auth/forgot-password` | Public | Not implemented |
|
||||
| [x] | POST | `/auth/forgot-password` | Public | Sends reset link via SMTP (`email_settings`) |
|
||||
| [x] | POST | `/auth/reset-password` | Public | Reset password with token from email |
|
||||
| [x] | GET | `/auth/me` | Authenticated | Current user + permissions (for FE) |
|
||||
| [x] | PUT | `/auth/profile` | Authenticated | Update `full_name` / `email` / `mobile` |
|
||||
| [x] | POST | `/auth/profile/avatar` | Authenticated | Upload avatar (`multipart`, field `avatar`) |
|
||||
| [x] | POST | `/auth/change-password` | Authenticated | Change password (revokes refresh tokens) |
|
||||
|
||||
**DB patch:** `scripts/patch-users-avatar.sql` — adds `users.avatar_path`.
|
||||
|
||||
**Env:** `FRONTEND_URL` (reset link base), `PASSWORD_RESET_EXPIRY_MINUTES` (default 60).
|
||||
|
||||
---
|
||||
|
||||
@ -358,7 +366,7 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
|
||||
| Module | Endpoints done | Endpoints total | Status |
|
||||
|--------|----------------|-----------------|--------|
|
||||
| System | 4 | 4 | [x] Done |
|
||||
| Auth | 3 | 5 | [ ] Partial |
|
||||
| Auth | 9 | 9 | [x] Done |
|
||||
| Users | 8 | 8 | [x] Done |
|
||||
| Roles | 10 | 10 | [x] Done |
|
||||
| Masters | 70 | 70 | [x] Done |
|
||||
|
||||
@ -44,7 +44,8 @@
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.18.3",
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"nodemailer": "^6.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.1",
|
||||
|
||||
@ -802,6 +802,7 @@ model users {
|
||||
mobile String? @db.VarChar(255)
|
||||
mobile_index String? @db.VarChar(64)
|
||||
password_hash String @db.VarChar(255)
|
||||
avatar_path String? @db.VarChar(500)
|
||||
department_id BigInt?
|
||||
designation_id BigInt?
|
||||
plant_id BigInt?
|
||||
|
||||
4
scripts/patch-users-avatar.sql
Normal file
4
scripts/patch-users-avatar.sql
Normal file
@ -0,0 +1,4 @@
|
||||
-- Add user profile avatar path
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS avatar_path VARCHAR(500);
|
||||
@ -36,6 +36,9 @@ const envSchema = Joi.object({
|
||||
MAX_LOGIN_ATTEMPTS: Joi.number().default(5),
|
||||
LOCKOUT_DURATION_MINUTES: Joi.number().default(30),
|
||||
|
||||
FRONTEND_URL: Joi.string().uri({ allowRelative: false }).default('http://localhost:8080'),
|
||||
PASSWORD_RESET_EXPIRY_MINUTES: Joi.number().integer().min(5).default(60),
|
||||
|
||||
BITBUCKET_API_KEY: Joi.string().allow('').optional(),
|
||||
EXPECTED_REPO: Joi.string().allow('').optional(),
|
||||
EXPECTED_BRANCH: Joi.string().allow('').optional(),
|
||||
|
||||
@ -39,6 +39,33 @@ components:
|
||||
required: [refresh_token]
|
||||
properties:
|
||||
refresh_token: { type: string, example: "<refresh-token>" }
|
||||
AuthUpdateProfileBody:
|
||||
type: object
|
||||
minProperties: 1
|
||||
properties:
|
||||
full_name: { type: string, example: 'suren' }
|
||||
name: { type: string, example: 'suren', description: 'Alias for full_name' }
|
||||
email: { type: string, format: email, example: 'surendar.m@venbaitinfotech.com' }
|
||||
mobile: { type: string, example: '9874563698' }
|
||||
AuthChangePasswordBody:
|
||||
type: object
|
||||
required: [current_password, new_password, confirm_password]
|
||||
properties:
|
||||
current_password: { type: string, example: 'OldPass@123' }
|
||||
new_password: { type: string, example: 'NewPass@123' }
|
||||
confirm_password: { type: string, example: 'NewPass@123' }
|
||||
AuthForgotPasswordBody:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email: { type: string, format: email, example: 'surendar.m@venbaitinfotech.com' }
|
||||
AuthResetPasswordBody:
|
||||
type: object
|
||||
required: [token, new_password, confirm_password]
|
||||
properties:
|
||||
token: { type: string, example: 'raw-token-from-email-link' }
|
||||
new_password: { type: string, example: 'NewPass@123' }
|
||||
confirm_password: { type: string, example: 'NewPass@123' }
|
||||
AuthMeData:
|
||||
type: object
|
||||
properties:
|
||||
@ -47,6 +74,8 @@ components:
|
||||
full_name: { type: string, example: Super Admin }
|
||||
email: { type: string, format: email, example: "admin@bharaterp.com" }
|
||||
mobile: { type: string, nullable: true, example: "9876543210" }
|
||||
avatar_path: { type: string, nullable: true }
|
||||
avatar_url: { type: string, nullable: true, example: '/uploads/avatars/abc.png' }
|
||||
status: { type: string, example: active }
|
||||
is_active: { type: boolean, example: true }
|
||||
last_login_at: { type: string, format: date-time, nullable: true }
|
||||
@ -575,6 +604,87 @@ paths:
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/AuthMeData" }
|
||||
"401": { description: Unauthorized }
|
||||
/auth/profile:
|
||||
put:
|
||||
tags: [Auth]
|
||||
summary: Update current user profile
|
||||
description: Updates name, email, and/or mobile. Department and role are read-only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AuthUpdateProfileBody" }
|
||||
responses:
|
||||
"200":
|
||||
description: Profile updated
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||
"409": { description: Email already in use }
|
||||
/auth/profile/avatar:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Upload profile avatar
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [avatar]
|
||||
properties:
|
||||
avatar:
|
||||
type: string
|
||||
format: binary
|
||||
description: JPEG, PNG, or WebP image
|
||||
responses:
|
||||
"200":
|
||||
description: Avatar updated
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||
/auth/change-password:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Change password for current user
|
||||
description: Validates current password, then updates password and revokes all refresh tokens.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AuthChangePasswordBody" }
|
||||
responses:
|
||||
"200":
|
||||
description: Password changed
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||
"400": { description: Current password incorrect or passwords mismatch }
|
||||
/auth/forgot-password:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Request password reset email
|
||||
description: Uses SMTP from Settings → Email. Always returns a generic success message. FE should open `/reset-password?token=...` from the email link.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AuthForgotPasswordBody" }
|
||||
responses:
|
||||
"200":
|
||||
description: If account exists, reset email was sent
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||
"503": { description: Email settings not configured }
|
||||
/auth/reset-password:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Reset password using email token
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AuthResetPasswordBody" }
|
||||
responses:
|
||||
"200":
|
||||
description: Password reset
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||
"400": { description: Invalid or expired token }
|
||||
/masters/uom:
|
||||
get:
|
||||
tags: [UOM]
|
||||
|
||||
@ -22,4 +22,39 @@ const me = asyncHandler(async (req, res) => {
|
||||
res.json(new ApiResponse(200, data, 'Current user fetched'));
|
||||
});
|
||||
|
||||
module.exports = { login, refresh, logout, me };
|
||||
const updateProfile = asyncHandler(async (req, res) => {
|
||||
const data = await authService.updateProfile(req.user.id, req.body, req.id);
|
||||
res.json(new ApiResponse(200, data, 'Profile updated successfully'));
|
||||
});
|
||||
|
||||
const uploadAvatar = asyncHandler(async (req, res) => {
|
||||
const data = await authService.updateAvatar(req.user.id, req.file, req.id);
|
||||
res.json(new ApiResponse(200, data, 'Avatar updated successfully'));
|
||||
});
|
||||
|
||||
const changePassword = asyncHandler(async (req, res) => {
|
||||
await authService.changePassword(req.user.id, req.body, req.id);
|
||||
res.json(new ApiResponse(200, null, 'Password changed successfully. Please login again.'));
|
||||
});
|
||||
|
||||
const forgotPassword = asyncHandler(async (req, res) => {
|
||||
const result = await authService.forgotPassword(req.body.email);
|
||||
res.json(new ApiResponse(200, null, result.message));
|
||||
});
|
||||
|
||||
const resetPassword = asyncHandler(async (req, res) => {
|
||||
await authService.resetPassword(req.body);
|
||||
res.json(new ApiResponse(200, null, 'Password reset successfully. Please login.'));
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
login,
|
||||
refresh,
|
||||
logout,
|
||||
me,
|
||||
updateProfile,
|
||||
uploadAvatar,
|
||||
changePassword,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
};
|
||||
|
||||
@ -2,7 +2,15 @@ const express = require('express');
|
||||
const authenticate = require('../../middlewares/auth.middleware');
|
||||
const validate = require('../../middlewares/validate.middleware');
|
||||
const { authLimiter } = require('../../middlewares/rateLimiter.middleware');
|
||||
const { loginSchema, refreshSchema } = require('./auth.validation');
|
||||
const {
|
||||
loginSchema,
|
||||
refreshSchema,
|
||||
updateProfileSchema,
|
||||
changePasswordSchema,
|
||||
forgotPasswordSchema,
|
||||
resetPasswordSchema,
|
||||
} = require('./auth.validation');
|
||||
const { avatarUpload } = require('./auth.upload.middleware');
|
||||
const controller = require('./auth.controller');
|
||||
|
||||
const router = express.Router();
|
||||
@ -10,6 +18,27 @@ const router = express.Router();
|
||||
router.post('/login', authLimiter, validate(loginSchema), controller.login);
|
||||
router.post('/refresh', validate(refreshSchema), controller.refresh);
|
||||
router.post('/logout', validate(refreshSchema), controller.logout);
|
||||
router.post(
|
||||
'/forgot-password',
|
||||
authLimiter,
|
||||
validate(forgotPasswordSchema),
|
||||
controller.forgotPassword
|
||||
);
|
||||
router.post('/reset-password', authLimiter, validate(resetPasswordSchema), controller.resetPassword);
|
||||
|
||||
router.get('/me', authenticate, controller.me);
|
||||
router.put('/profile', authenticate, validate(updateProfileSchema), controller.updateProfile);
|
||||
router.post(
|
||||
'/profile/avatar',
|
||||
authenticate,
|
||||
avatarUpload.single('avatar'),
|
||||
controller.uploadAvatar
|
||||
);
|
||||
router.post(
|
||||
'/change-password',
|
||||
authenticate,
|
||||
validate(changePasswordSchema),
|
||||
controller.changePassword
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -1,14 +1,92 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const prisma = require('../../config/prisma');
|
||||
const ApiError = require('../../utils/ApiError');
|
||||
const auditLog = require('../../utils/auditLog');
|
||||
const env = require('../../config/env');
|
||||
const { decrypt } = require('../../utils/encryption');
|
||||
const { encrypt, decrypt, blindIndex } = require('../../utils/encryption');
|
||||
const { collectUserPermissions, extractUserRoles } = require('../../utils/userPermissions');
|
||||
const { sendMail } = require('../../utils/email');
|
||||
|
||||
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
const buildPublicUrl = (filePath) => {
|
||||
if (!filePath) return null;
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
return normalized.startsWith('/') ? normalized : `/${normalized}`;
|
||||
};
|
||||
|
||||
const unlinkIfExists = (relativePath) => {
|
||||
if (!relativePath) return;
|
||||
const absolute = path.resolve(process.cwd(), relativePath);
|
||||
if (fs.existsSync(absolute)) {
|
||||
fs.unlinkSync(absolute);
|
||||
}
|
||||
};
|
||||
|
||||
const profileInclude = {
|
||||
user_roles: {
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role_permissions: {
|
||||
include: {
|
||||
permissions: { include: { modules: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
departments: { select: { id: true, name: true } },
|
||||
designations: { select: { id: true, name: true } },
|
||||
plant_location: { select: { id: true, code: true, name: true } },
|
||||
};
|
||||
|
||||
const getActiveUserOrThrow = async (userId) => {
|
||||
const user = await prisma.users.findFirst({
|
||||
where: { id: BigInt(userId), deleted_at: null },
|
||||
include: profileInclude,
|
||||
});
|
||||
|
||||
if (!user || !user.is_active || user.status !== 'active') {
|
||||
throw new ApiError(401, 'Invalid or inactive user');
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
const sanitizeProfile = (user) => {
|
||||
const roles = extractUserRoles(user).map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
}));
|
||||
const permissions = collectUserPermissions(user);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
employee_code: user.employee_code,
|
||||
full_name: user.full_name,
|
||||
email: user.email,
|
||||
mobile: user.mobile ? decrypt(user.mobile) : null,
|
||||
avatar_path: user.avatar_path || null,
|
||||
avatar_url: buildPublicUrl(user.avatar_path),
|
||||
status: user.status,
|
||||
is_active: user.is_active,
|
||||
last_login_at: user.last_login_at,
|
||||
roles,
|
||||
role: roles[0] || null,
|
||||
department: user.departments,
|
||||
designation: user.designations,
|
||||
plant: user.plant_location,
|
||||
permissions,
|
||||
};
|
||||
};
|
||||
|
||||
const issueTokens = async (user) => {
|
||||
const accessToken = jwt.sign({ sub: user.id.toString() }, env.JWT_ACCESS_SECRET, {
|
||||
expiresIn: env.JWT_ACCESS_EXPIRY,
|
||||
@ -91,56 +169,278 @@ const logout = async (refreshToken) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getMe = async (userId) => {
|
||||
const user = await prisma.users.findFirst({
|
||||
where: { id: BigInt(userId), deleted_at: null },
|
||||
include: {
|
||||
user_roles: {
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role_permissions: {
|
||||
include: {
|
||||
permissions: { include: { modules: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
const getMe = async (userId) => sanitizeProfile(await getActiveUserOrThrow(userId));
|
||||
|
||||
const updateProfile = async (userId, payload, requestId) => {
|
||||
const existing = await getActiveUserOrThrow(userId);
|
||||
const fullName = payload.full_name ?? payload.name;
|
||||
|
||||
const data = {
|
||||
...(fullName !== undefined ? { full_name: fullName } : {}),
|
||||
updated_by: BigInt(userId),
|
||||
};
|
||||
|
||||
if (payload.email !== undefined) {
|
||||
const email = String(payload.email).trim().toLowerCase();
|
||||
if (email !== existing.email.toLowerCase()) {
|
||||
const duplicate = await prisma.users.findFirst({
|
||||
where: {
|
||||
email: { equals: email, mode: 'insensitive' },
|
||||
deleted_at: null,
|
||||
NOT: { id: existing.id },
|
||||
},
|
||||
},
|
||||
departments: { select: { id: true, name: true } },
|
||||
designations: { select: { id: true, name: true } },
|
||||
plant_location: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (duplicate) throw new ApiError(409, 'Email is already in use by another user');
|
||||
}
|
||||
data.email = email;
|
||||
}
|
||||
|
||||
if (payload.mobile !== undefined) {
|
||||
const mobile = payload.mobile || null;
|
||||
if (mobile) {
|
||||
data.mobile = encrypt(mobile);
|
||||
data.mobile_index = blindIndex(mobile);
|
||||
} else {
|
||||
data.mobile = null;
|
||||
data.mobile_index = null;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.users.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
include: profileInclude,
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
tableName: 'users',
|
||||
recordId: existing.id,
|
||||
action: 'UPDATE',
|
||||
oldValue: {
|
||||
full_name: existing.full_name,
|
||||
email: existing.email,
|
||||
mobile: existing.mobile ? decrypt(existing.mobile) : null,
|
||||
},
|
||||
newValue: {
|
||||
full_name: updated.full_name,
|
||||
email: updated.email,
|
||||
mobile: updated.mobile ? decrypt(updated.mobile) : null,
|
||||
},
|
||||
userId,
|
||||
requestId,
|
||||
});
|
||||
|
||||
return sanitizeProfile(updated);
|
||||
};
|
||||
|
||||
const updateAvatar = async (userId, file, requestId) => {
|
||||
if (!file) throw new ApiError(400, 'Avatar file is required');
|
||||
|
||||
const existing = await getActiveUserOrThrow(userId);
|
||||
unlinkIfExists(existing.avatar_path);
|
||||
|
||||
const avatarPath = path.join(env.UPLOAD_DIR, 'avatars', file.filename).replace(/\\/g, '/');
|
||||
const updated = await prisma.users.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
avatar_path: avatarPath,
|
||||
updated_by: BigInt(userId),
|
||||
},
|
||||
include: profileInclude,
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
tableName: 'users',
|
||||
recordId: existing.id,
|
||||
action: 'UPDATE',
|
||||
oldValue: { avatar_path: existing.avatar_path },
|
||||
newValue: { avatar_path: updated.avatar_path },
|
||||
userId,
|
||||
requestId,
|
||||
});
|
||||
|
||||
return sanitizeProfile(updated);
|
||||
};
|
||||
|
||||
const changePassword = async (userId, payload, requestId) => {
|
||||
const user = await prisma.users.findFirst({
|
||||
where: { id: BigInt(userId), deleted_at: null },
|
||||
});
|
||||
if (!user || !user.is_active || user.status !== 'active') {
|
||||
throw new ApiError(401, 'Invalid or inactive user');
|
||||
}
|
||||
|
||||
const roles = extractUserRoles(user).map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
}));
|
||||
const permissions = collectUserPermissions(user);
|
||||
const match = await bcrypt.compare(payload.current_password, user.password_hash);
|
||||
if (!match) throw new ApiError(400, 'Current password is incorrect');
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
employee_code: user.employee_code,
|
||||
full_name: user.full_name,
|
||||
email: user.email,
|
||||
mobile: user.mobile ? decrypt(user.mobile) : null,
|
||||
status: user.status,
|
||||
is_active: user.is_active,
|
||||
last_login_at: user.last_login_at,
|
||||
roles,
|
||||
role: roles[0] || null,
|
||||
department: user.departments,
|
||||
designation: user.designations,
|
||||
plant: user.plant_location,
|
||||
permissions,
|
||||
};
|
||||
if (payload.current_password === payload.new_password) {
|
||||
throw new ApiError(400, 'New password must be different from current password');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(payload.new_password, env.BCRYPT_SALT_ROUNDS);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password_hash: passwordHash,
|
||||
failed_login_attempts: 0,
|
||||
locked_until: null,
|
||||
updated_by: BigInt(userId),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.refresh_tokens.updateMany({
|
||||
where: { user_id: user.id, revoked_at: null },
|
||||
data: { revoked_at: new Date() },
|
||||
});
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
tableName: 'users',
|
||||
recordId: user.id,
|
||||
action: 'UPDATE',
|
||||
oldValue: { password_changed: false },
|
||||
newValue: { password_changed: true },
|
||||
userId,
|
||||
requestId,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { login, refresh, logout, getMe };
|
||||
const forgotPassword = async (email) => {
|
||||
const genericMessage =
|
||||
'If an account exists for this email, a password reset link has been sent';
|
||||
|
||||
const user = await prisma.users.findFirst({
|
||||
where: {
|
||||
email: { equals: String(email).trim(), mode: 'insensitive' },
|
||||
deleted_at: null,
|
||||
is_active: true,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { message: genericMessage };
|
||||
}
|
||||
|
||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + env.PASSWORD_RESET_EXPIRY_MINUTES * 60 * 1000);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.password_reset_tokens.updateMany({
|
||||
where: { user_id: user.id, used_at: null },
|
||||
data: { used_at: new Date() },
|
||||
});
|
||||
|
||||
await tx.password_reset_tokens.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
token_hash: tokenHash,
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const resetUrl = `${env.FRONTEND_URL.replace(/\/$/, '')}/reset-password?token=${rawToken}`;
|
||||
const subject = 'Reset your ERP password';
|
||||
const text = [
|
||||
`Hello ${user.full_name},`,
|
||||
'',
|
||||
'We received a request to reset your password.',
|
||||
`Open this link to set a new password (valid for ${env.PASSWORD_RESET_EXPIRY_MINUTES} minutes):`,
|
||||
resetUrl,
|
||||
'',
|
||||
'If you did not request this, you can ignore this email.',
|
||||
].join('\n');
|
||||
|
||||
const html = `
|
||||
<p>Hello ${user.full_name},</p>
|
||||
<p>We received a request to reset your password.</p>
|
||||
<p><a href="${resetUrl}">Click here to reset your password</a></p>
|
||||
<p>This link expires in ${env.PASSWORD_RESET_EXPIRY_MINUTES} minutes.</p>
|
||||
<p>If you did not request this, you can ignore this email.</p>
|
||||
`;
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
|
||||
return { message: genericMessage };
|
||||
};
|
||||
|
||||
const resetPassword = async (payload) => {
|
||||
const tokenHash = hashToken(payload.token);
|
||||
const record = await prisma.password_reset_tokens.findFirst({
|
||||
where: {
|
||||
token_hash: tokenHash,
|
||||
used_at: null,
|
||||
expires_at: { gt: new Date() },
|
||||
},
|
||||
include: { users: true },
|
||||
});
|
||||
|
||||
if (!record || !record.users || record.users.deleted_at) {
|
||||
throw new ApiError(400, 'Invalid or expired password reset token');
|
||||
}
|
||||
|
||||
const user = record.users;
|
||||
if (!user.is_active || user.status !== 'active') {
|
||||
throw new ApiError(403, 'Account is inactive. Contact administrator.');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(payload.new_password, env.BCRYPT_SALT_ROUNDS);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password_hash: passwordHash,
|
||||
failed_login_attempts: 0,
|
||||
locked_until: null,
|
||||
updated_by: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.password_reset_tokens.update({
|
||||
where: { id: record.id },
|
||||
data: { used_at: new Date() },
|
||||
});
|
||||
|
||||
await tx.password_reset_tokens.updateMany({
|
||||
where: { user_id: user.id, used_at: null },
|
||||
data: { used_at: new Date() },
|
||||
});
|
||||
|
||||
await tx.refresh_tokens.updateMany({
|
||||
where: { user_id: user.id, revoked_at: null },
|
||||
data: { revoked_at: new Date() },
|
||||
});
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
tableName: 'users',
|
||||
recordId: user.id,
|
||||
action: 'UPDATE',
|
||||
oldValue: { password_reset: false },
|
||||
newValue: { password_reset: true },
|
||||
userId: user.id,
|
||||
requestId: null,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
login,
|
||||
refresh,
|
||||
logout,
|
||||
getMe,
|
||||
updateProfile,
|
||||
updateAvatar,
|
||||
changePassword,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
};
|
||||
|
||||
31
src/modules/auth/auth.upload.middleware.js
Normal file
31
src/modules/auth/auth.upload.middleware.js
Normal file
@ -0,0 +1,31 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const ApiError = require('../../utils/ApiError');
|
||||
const env = require('../../config/env');
|
||||
|
||||
const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
const avatarUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
const dir = path.join(env.UPLOAD_DIR, 'avatars');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const unique = crypto.randomBytes(16).toString('hex');
|
||||
cb(null, `${unique}${path.extname(file.originalname).toLowerCase()}`);
|
||||
},
|
||||
}),
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!ALLOWED_MIME.includes(file.mimetype)) {
|
||||
return cb(new ApiError(400, `Unsupported file type: ${file.mimetype}`), false);
|
||||
}
|
||||
return cb(null, true);
|
||||
},
|
||||
limits: { fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024 },
|
||||
});
|
||||
|
||||
module.exports = { avatarUpload };
|
||||
@ -9,4 +9,42 @@ const refreshSchema = Joi.object({
|
||||
refresh_token: Joi.string().required(),
|
||||
});
|
||||
|
||||
module.exports = { loginSchema, refreshSchema };
|
||||
const updateProfileSchema = Joi.object({
|
||||
full_name: Joi.string().max(200).optional(),
|
||||
name: Joi.string().max(200).optional(),
|
||||
email: Joi.string().email().max(200).optional(),
|
||||
mobile: Joi.string().max(20).allow(null, '').optional(),
|
||||
})
|
||||
.or('full_name', 'name', 'email', 'mobile')
|
||||
.messages({
|
||||
'object.missing': 'At least one of full_name, email, or mobile is required',
|
||||
});
|
||||
|
||||
const changePasswordSchema = Joi.object({
|
||||
current_password: Joi.string().min(8).required(),
|
||||
new_password: Joi.string().min(8).required(),
|
||||
confirm_password: Joi.string().valid(Joi.ref('new_password')).required().messages({
|
||||
'any.only': 'confirm_password must match new_password',
|
||||
}),
|
||||
});
|
||||
|
||||
const forgotPasswordSchema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
});
|
||||
|
||||
const resetPasswordSchema = Joi.object({
|
||||
token: Joi.string().required(),
|
||||
new_password: Joi.string().min(8).required(),
|
||||
confirm_password: Joi.string().valid(Joi.ref('new_password')).required().messages({
|
||||
'any.only': 'confirm_password must match new_password',
|
||||
}),
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
loginSchema,
|
||||
refreshSchema,
|
||||
updateProfileSchema,
|
||||
changePasswordSchema,
|
||||
forgotPasswordSchema,
|
||||
resetPasswordSchema,
|
||||
};
|
||||
|
||||
35
src/utils/email.js
Normal file
35
src/utils/email.js
Normal file
@ -0,0 +1,35 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const ApiError = require('./ApiError');
|
||||
const { getSmtpConfig } = require('../modules/settings/settings.service');
|
||||
|
||||
const sendMail = async ({ to, subject, text, html }) => {
|
||||
const smtp = await getSmtpConfig();
|
||||
if (!smtp?.host) {
|
||||
throw new ApiError(503, 'Email service is not configured. Update Settings → Email.');
|
||||
}
|
||||
if (!smtp.fromEmail) {
|
||||
throw new ApiError(503, 'Sender email is not configured in email settings');
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port || 587,
|
||||
secure: Number(smtp.port) === 465,
|
||||
auth: smtp.username
|
||||
? {
|
||||
user: smtp.username,
|
||||
pass: smtp.password || '',
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtp.fromName ? `"${smtp.fromName}" <${smtp.fromEmail}>` : smtp.fromEmail,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { sendMail };
|
||||
Loading…
Reference in New Issue
Block a user