const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); const mongoose = require('mongoose'); const { asyncController } = require('../utils/asyncController'); const { recordAuditTrail } = require('../services/auditTrailService'); const AdminUser = require('../models/AdminUser'); const { ensureAdminInitialized } = require('../services/adminAuthService'); const { isValidPersonName } = require('../validation/commonValidation'); const { env } = require('../configs/env'); const JWT_SECRET = env.jwtSecret; const BCRYPT_SALT_ROUNDS = 12; const USERNAME_PATTERN = /^[a-z0-9._-]{3,30}$/; const buildAuthUser = (admin) => ({ id: String(admin._id), username: admin.username, name: admin.name, role: 'admin', isActive: admin.isActive !== false, }); const generateToken = (user, sessionToken) => { if (!JWT_SECRET) { throw new Error('JWT_SECRET is not configured'); } const payload = { id: user.id }; if (sessionToken) payload.st = sessionToken; return jwt.sign(payload, JWT_SECRET, { expiresIn: env.jwtExpire, }); }; const normalizeUsername = (value) => String(value || '').trim().toLowerCase(); const buildManagedUser = (admin) => ({ id: String(admin._id), username: admin.username, name: admin.name, role: admin.role || 'admin', isActive: admin.isActive !== false, createdAt: admin.createdAt, updatedAt: admin.updatedAt, }); const login = asyncController(async (req, res) => { await ensureAdminInitialized(); const identifier = String(req.body?.email || req.body?.username || '').trim(); const password = String(req.body?.password || ''); if (!identifier || !password) { return res.status(400).json({ message: 'Username and password are required' }); } const admin = await AdminUser.findOne({ username: identifier.toLowerCase(), }).select('+passwordHash'); if (!admin) { return res.status(401).json({ message: 'Incorrect username or password.' }); } if (admin.isActive === false) { return res.status(403).json({ message: 'This account is inactive' }); } const isValidPass = await bcrypt.compare(password, admin.passwordHash || ''); if (!isValidPass) { return res.status(401).json({ message: 'Incorrect username or password.' }); } const sessionToken = crypto.randomBytes(32).toString('hex'); await AdminUser.findByIdAndUpdate(admin._id, { sessionToken }); const user = buildAuthUser(admin); const token = generateToken(user, sessionToken); void recordAuditTrail({ req, actor: { id: user.id, name: user.name }, action: 'login', moduleName: 'auth', summary: 'Admin logged in', endpoint: 'POST /api/auth/login', method: 'POST', statusCode: 200, }); res.cookie('token', token, { httpOnly: true, secure: env.isProduction, sameSite: 'strict', }); return res.json({ token, user }); }); const register = async (req, res) => { return res.status(501).json({ message: 'Registration is disabled for this project' }); }; const getMe = (req, res) => { return res.json(req.user || null); }; const updateProfile = asyncController(async (req, res) => { await ensureAdminInitialized(); const { currentPassword, newName, newUsername, newPassword } = req.body || {}; if (!currentPassword) { return res.status(400).json({ message: 'Current password is required' }); } const adminId = req.user?.id || req.user?._id; if (!adminId) { return res.status(401).json({ message: 'Not authorized, invalid token' }); } const admin = await AdminUser.findById(adminId).select('+passwordHash'); if (!admin) { return res.status(404).json({ message: 'Account not found' }); } const isCurrentPasswordValid = await bcrypt.compare(currentPassword, admin.passwordHash || ''); if (!isCurrentPasswordValid) { return res.status(401).json({ message: 'Current password is incorrect' }); } if (newUsername !== undefined) { const normalizedUsername = normalizeUsername(newUsername); if (!USERNAME_PATTERN.test(normalizedUsername)) { return res.status(400).json({ message: 'Username must be 3-30 characters using letters, numbers, dots, dashes, or underscores', }); } if (normalizedUsername !== admin.username) { const usernameInUse = await AdminUser.findOne({ username: normalizedUsername, _id: { $ne: admin._id }, }) .select('_id') .lean(); if (usernameInUse) { return res.status(409).json({ message: 'Username is already taken' }); } admin.username = normalizedUsername; } } if (newName && newName.trim()) { if (!isValidPersonName(newName)) { return res.status(400).json({ message: 'Display name must contain letters only (no numbers)' }); } admin.name = newName.trim(); } if (newPassword) { if (newPassword.length < env.adminPasswordMinLength) { return res.status(400).json({ message: `New password must be at least ${env.adminPasswordMinLength} characters` }); } admin.passwordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS); } await admin.save(); const updatedAdmin = await AdminUser.findById(admin._id).select('+sessionToken').lean(); const user = buildAuthUser(admin); const token = generateToken(user, updatedAdmin?.sessionToken || undefined); res.cookie('token', token, { httpOnly: true, secure: env.isProduction, sameSite: 'strict', }); return res.json({ user, message: 'Profile updated successfully' }); }); const listUsers = asyncController(async (_req, res) => { await ensureAdminInitialized(); const users = await AdminUser.find({}) .sort({ createdAt: 1 }) .select('_id username name role isActive createdAt updatedAt') .lean(); return res.json((users || []).map(buildManagedUser)); }); const createUser = asyncController(async (req, res) => { await ensureAdminInitialized(); const username = normalizeUsername(req.body?.username); const name = String(req.body?.name || '').trim(); const password = String(req.body?.password || ''); const isActive = req.body?.isActive !== false; if (!USERNAME_PATTERN.test(username)) { return res.status(400).json({ message: 'Username must be 3-30 characters using letters, numbers, dots, dashes, or underscores', }); } if (!name) { return res.status(400).json({ message: 'Display name is required' }); } if (!isValidPersonName(name)) { return res.status(400).json({ message: 'Display name must contain letters only (no numbers)' }); } if (password.length < env.adminPasswordMinLength) { return res.status(400).json({ message: `Password must be at least ${env.adminPasswordMinLength} characters` }); } const usernameInUse = await AdminUser.findOne({ username }).select('_id').lean(); if (usernameInUse) { return res.status(409).json({ message: 'Username is already taken' }); } const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS); const admin = await AdminUser.create({ username, name, passwordHash, role: 'admin', isActive, }); return res.status(201).json(buildManagedUser(admin)); }); const updateUser = asyncController(async (req, res) => { await ensureAdminInitialized(); const targetId = String(req.params?.id || '').trim(); if (!targetId) { return res.status(400).json({ message: 'User id is required' }); } const admin = await AdminUser.findById(targetId).select('+passwordHash'); if (!admin) { return res.status(404).json({ message: 'User not found' }); } const requesterId = String(req.user?.id || req.user?._id || ''); const hasUsername = Object.prototype.hasOwnProperty.call(req.body || {}, 'username'); const hasName = Object.prototype.hasOwnProperty.call(req.body || {}, 'name'); const hasPassword = Object.prototype.hasOwnProperty.call(req.body || {}, 'password'); const hasIsActive = Object.prototype.hasOwnProperty.call(req.body || {}, 'isActive'); if (hasUsername) { const username = normalizeUsername(req.body?.username); if (!USERNAME_PATTERN.test(username)) { return res.status(400).json({ message: 'Username must be 3-30 characters using letters, numbers, dots, dashes, or underscores', }); } if (username !== admin.username) { const usernameInUse = await AdminUser.findOne({ username, _id: { $ne: admin._id } }).select('_id').lean(); if (usernameInUse) { return res.status(409).json({ message: 'Username is already taken' }); } admin.username = username; } } if (hasName) { const name = String(req.body?.name || '').trim(); if (!name) { return res.status(400).json({ message: 'Display name is required' }); } if (!isValidPersonName(name)) { return res.status(400).json({ message: 'Display name must contain letters only (no numbers)' }); } admin.name = name; } if (hasPassword) { const password = String(req.body?.password || ''); if (password && password.length < env.adminPasswordMinLength) { return res.status(400).json({ message: `Password must be at least ${env.adminPasswordMinLength} characters` }); } if (password) { admin.passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS); } } if (hasIsActive) { const nextIsActive = req.body?.isActive !== false; if (requesterId && requesterId === String(admin._id) && !nextIsActive) { return res.status(400).json({ message: 'You cannot deactivate your own account' }); } if (!nextIsActive && admin.isActive !== false) { const session = await mongoose.startSession(); let saved; try { await session.withTransaction(async () => { const activeCount = await AdminUser.countDocuments( { isActive: { $ne: false } }, { session } ); if (activeCount <= 1) { throw Object.assign(new Error('At least one active admin account is required'), { statusCode: 400 }); } admin.isActive = false; saved = await admin.save({ session }); }); } catch (txErr) { if (txErr.statusCode === 400) { return res.status(400).json({ message: txErr.message }); } throw txErr; } finally { await session.endSession(); } return res.json(buildManagedUser(saved || admin)); } admin.isActive = nextIsActive; } await admin.save(); return res.json(buildManagedUser(admin)); }); const deleteUser = asyncController(async (req, res) => { await ensureAdminInitialized(); const targetId = String(req.params?.id || '').trim(); if (!targetId) { return res.status(400).json({ message: 'User id is required' }); } const requesterId = String(req.user?.id || req.user?._id || ''); if (requesterId && requesterId === targetId) { return res.status(400).json({ message: 'You cannot delete your own account' }); } const targetUser = await AdminUser.findById(targetId).select('_id isActive').lean(); if (!targetUser) { return res.status(404).json({ message: 'User not found' }); } if (targetUser.isActive !== false) { const session = await mongoose.startSession(); try { await session.withTransaction(async () => { const activeCount = await AdminUser.countDocuments( { isActive: { $ne: false } }, { session } ); if (activeCount <= 1) { throw Object.assign(new Error('At least one active admin account is required'), { statusCode: 400 }); } const deleted = await AdminUser.findByIdAndDelete(targetId, { session }).select('_id').lean(); if (!deleted) { throw Object.assign(new Error('User not found'), { statusCode: 404 }); } }); } catch (txErr) { if (txErr.statusCode === 400) return res.status(400).json({ message: txErr.message }); if (txErr.statusCode === 404) return res.status(404).json({ message: txErr.message }); throw txErr; } finally { await session.endSession(); } return res.status(204).send(); } const deleted = await AdminUser.findByIdAndDelete(targetId).select('_id').lean(); if (!deleted) { return res.status(404).json({ message: 'User not found' }); } return res.status(204).send(); }); const logout = asyncController(async (req, res) => { const token = req.cookies?.token; let actorId = 'unknown'; let actorName = 'Administrator'; if (token) { try { const decoded = jwt.decode(token); if (decoded?.id) { actorId = String(decoded.id); const admin = await AdminUser.findById(actorId).select('name').lean(); if (admin?.name) actorName = admin.name; } } catch (_) {} } if (actorId !== 'unknown') { try { await AdminUser.findByIdAndUpdate(actorId, { sessionToken: null }); } catch (_) {} } void recordAuditTrail({ req, actor: { id: actorId, name: actorName }, action: 'logout', moduleName: 'auth', summary: 'Admin logged out', endpoint: 'POST /api/auth/logout', method: 'POST', statusCode: 200, }); res.clearCookie('token', { httpOnly: true, secure: env.isProduction, sameSite: 'strict' }); return res.json({ message: 'Logged out' }); }); module.exports = { login, logout, register, getMe, updateProfile, listUsers, createUser, updateUser, deleteUser, };