diff --git a/.env.example b/.env.example index 3701408..1573689 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md index 6762d12..c28eace 100644 --- a/BACKEND_TASKS.md +++ b/BACKEND_TASKS.md @@ -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 ` (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 | diff --git a/package.json b/package.json index 5bf00b5..1cc14ed 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ad2bb72..a283187 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/scripts/patch-users-avatar.sql b/scripts/patch-users-avatar.sql new file mode 100644 index 0000000..275bde3 --- /dev/null +++ b/scripts/patch-users-avatar.sql @@ -0,0 +1,4 @@ +-- Add user profile avatar path + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS avatar_path VARCHAR(500); diff --git a/src/config/env.js b/src/config/env.js index debf810..8554fdc 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -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(), diff --git a/src/docs/completed-routes.yaml b/src/docs/completed-routes.yaml index 5a07bab..192b541 100644 --- a/src/docs/completed-routes.yaml +++ b/src/docs/completed-routes.yaml @@ -39,6 +39,33 @@ components: required: [refresh_token] properties: refresh_token: { type: string, example: "" } + 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] diff --git a/src/modules/auth/auth.controller.js b/src/modules/auth/auth.controller.js index 5556676..c861cb2 100644 --- a/src/modules/auth/auth.controller.js +++ b/src/modules/auth/auth.controller.js @@ -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, +}; diff --git a/src/modules/auth/auth.routes.js b/src/modules/auth/auth.routes.js index d16221a..ce7aa5e 100644 --- a/src/modules/auth/auth.routes.js +++ b/src/modules/auth/auth.routes.js @@ -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; diff --git a/src/modules/auth/auth.service.js b/src/modules/auth/auth.service.js index cab4a3a..9ccb98d 100644 --- a/src/modules/auth/auth.service.js +++ b/src/modules/auth/auth.service.js @@ -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 = ` +

Hello ${user.full_name},

+

We received a request to reset your password.

+

Click here to reset your password

+

This link expires in ${env.PASSWORD_RESET_EXPIRY_MINUTES} minutes.

+

If you did not request this, you can ignore this email.

+ `; + + 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, +}; diff --git a/src/modules/auth/auth.upload.middleware.js b/src/modules/auth/auth.upload.middleware.js new file mode 100644 index 0000000..b261aad --- /dev/null +++ b/src/modules/auth/auth.upload.middleware.js @@ -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 }; diff --git a/src/modules/auth/auth.validation.js b/src/modules/auth/auth.validation.js index b48a2a6..c1500f3 100644 --- a/src/modules/auth/auth.validation.js +++ b/src/modules/auth/auth.validation.js @@ -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, +}; diff --git a/src/utils/email.js b/src/utils/email.js new file mode 100644 index 0000000..d510611 --- /dev/null +++ b/src/utils/email.js @@ -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 };