Spaces:
Running
Running
| 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, | |
| }; | |