const bcrypt = require('bcryptjs') const usersDb = require('./users.db') const SALT_ROUNDS = 10 // Strip secrets (password hash, TOTP secret) before sending a user anywhere. function sanitize(user) { if (!user) return null const { password_hash, totp_secret, ...safe } = user return safe } // password may be omitted/null — an SSO-provisioned player has no password until // they set one (a null hash makes password login impossible, see validatePassword). async function createUser({ username, password, role = 'admin', email = null, status = 'active', emailVerified = false }) { const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null const id = await usersDb.insertUser({ username, passwordHash, role, email, status, emailVerified }) return sanitize(await usersDb.findById(id)) } // True when a DB error is the unique-index violation on username (the atomic // backstop for the uniqueness race). Callers translate this into a 409 rather // than doing a check-then-write. function isDuplicateUsername(err) { return Boolean(err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) } // Returns the raw row (incl. hash) — used by login only. async function getRawByUsername(username) { return usersDb.findByUsername(username) } async function getById(id) { return sanitize(await usersDb.findById(id)) } // Raw row incl. totp_secret — server-side only (TOTP setup/verify). Never sent // to a client; sanitize() strips the secret from anything user-facing. async function getRawById(id) { return usersDb.findById(id) } async function setTotpSecret(id, secret) { return usersDb.setTotpSecret(id, secret) } async function enableTotp(id) { return usersDb.enableTotp(id) } async function disableTotp(id) { return usersDb.disableTotp(id) } async function validatePassword(user, password) { if (!user || !user.password_hash) return false return bcrypt.compare(password, user.password_hash) } async function list() { return usersDb.listUsers() } async function update(id, { username, password, role, email, status, emailVerified }) { const fields = {} if (username !== undefined) fields.username = username if (role !== undefined) fields.role = role if (email !== undefined) fields.email = email if (status !== undefined) fields.status = status if (emailVerified !== undefined) fields.email_verified = emailVerified ? 1 : 0 if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS) await usersDb.updateUser(id, fields) // A password change must revoke existing sessions ("change password to log // everyone out"), so bump the cutoff whenever the hash was rotated. if (password) await usersDb.bumpTokensValidAfter(id) return getById(id) } // Invalidate every session token this user currently holds ("log out everywhere") // by advancing their tokens_valid_after cutoff to now. async function invalidateSessions(id) { return usersDb.bumpTokensValidAfter(id) } // Set the session cutoff to an explicit instant. Used by the self password-change // flow to keep the caller's freshly re-issued session alive (see users.db). async function setSessionCutoff(id, when) { return usersDb.setTokensValidAfter(id, when) } async function remove(id) { return usersDb.deleteUser(id) } async function count() { return usersDb.countUsers() } async function countAdmins() { return usersDb.countAdmins() } async function recordLogin(id, ip = null) { return usersDb.touchLastLogin(id, ip) } module.exports = { createUser, isDuplicateUsername, getRawByUsername, getById, getRawById, validatePassword, list, update, invalidateSessions, setSessionCutoff, remove, count, countAdmins, recordLogin, setTotpSecret, enableTotp, disableTotp, }