import bcrypt from 'bcrypt' import * as userRepo from '../repositories/userRepo.js' import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js' import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js' import { AppError } from '../middleware/error.js' import * as storageRepo from '../repositories/storageRepo.js' import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js' const SALT_ROUNDS = 10 const VERIFY_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000 const RESET_TOKEN_EXPIRY_MS = 60 * 60 * 1000 export interface AuthResult { token: string user: { id: number; email: string; displayName: string; role: string } } /** Response for a registration attempt we did not act on (email already exists). * Deliberately indistinguishable from success so the endpoint cannot be used * to enumerate registered email addresses. */ export interface RegistrationPending { message: string } function toAuthResult(user: userRepo.DbUser): AuthResult { return { token: signToken({ userId: user.id, email: user.email, displayName: user.display_name, role: user.role as 'user' | 'admin', tv: user.token_version ?? 0, }), user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, }, } } export async function register(input: RegisterInput): Promise { // Hash before the duplicate check so response timing does not reveal whether // the email is already registered (user enumeration) const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS) const existing = userRepo.getUserByEmail(input.email) if (existing) { return { message: 'Check your inbox to finish setting up your account.' } } const rawVerifyToken = generateVerificationToken() const verifyTokenHash = hashToken(rawVerifyToken) const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS const { id } = userRepo.createUser({ email: input.email, passwordHash, displayName: input.displayName, role: 'user', emailVerified: 0, verifyToken: verifyTokenHash, verifyExpires, }) const user = userRepo.getUserById(id)! // Don't await — fire and forget so registration isn't blocked by email failures sendVerificationEmail(user.email, rawVerifyToken).catch(() => {}) return toAuthResult(user) } export async function login(input: LoginInput): Promise { const user = userRepo.getUserByEmail(input.email) if (!user) { throw new AppError(401, 'Invalid email or password') } const valid = await bcrypt.compare(input.password, user.password_hash) if (!valid) { throw new AppError(401, 'Invalid email or password') } if (!user.email_verified) { throw new AppError(403, 'Please verify your email before logging in') } return toAuthResult(user) } export function getMe(userId: number) { const user = userRepo.getUserById(userId) if (!user) { throw new AppError(404, 'User not found') } const usage = storageRepo.getUserStorageUsage(userId) const usedPercent = user.storage_quota_bytes > 0 ? Math.round((usage.usedBytes / user.storage_quota_bytes) * 100) : 0 return { id: user.id, email: user.email, displayName: user.display_name, role: user.role, emailVerified: !!user.email_verified, storageQuotaBytes: user.storage_quota_bytes, usedBytes: usage.usedBytes, usedPercent, plan: user.plan, } } export async function updateProfile(userId: number, input: UpdateProfileInput) { userRepo.updateUser(userId, { displayName: input.displayName }) return getMe(userId) } export async function changePassword(userId: number, input: ChangePasswordInput) { const user = userRepo.getUserById(userId) if (!user) { throw new AppError(404, 'User not found') } const valid = await bcrypt.compare(input.currentPassword, user.password_hash) if (!valid) { throw new AppError(400, 'Current password is incorrect') } const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS) // Bump token version so tokens issued before the change stop working userRepo.updateUser(userId, { passwordHash, tokenVersion: (user.token_version ?? 0) + 1, }) } export async function verifyEmail(input: VerifyEmailInput) { const tokenHash = hashToken(input.token) const user = userRepo.getUserByVerifyTokenHash(tokenHash) if (!user || !user.verify_expires || Date.now() > user.verify_expires) { throw new AppError(400, 'Invalid or expired verification token') } userRepo.updateUser(user.id, { emailVerified: 1, verifyToken: null, verifyExpires: null, }) } export async function resendVerification(email: string) { const user = userRepo.getUserByEmail(email) if (!user || user.email_verified) { return } const rawVerifyToken = generateVerificationToken() const verifyTokenHash = hashToken(rawVerifyToken) const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS userRepo.updateUser(user.id, { verifyToken: verifyTokenHash, verifyExpires, }) sendVerificationEmail(user.email, rawVerifyToken).catch(() => {}) } export async function forgotPassword(email?: string) { if (!email) { throw new AppError(400, 'Email is required') } const user = userRepo.getUserByEmail(email) if (!user) { // Don't reveal whether email exists return } const rawResetToken = generateVerificationToken() const resetTokenHash = hashToken(rawResetToken) const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires }) // Fire and forget sendPasswordResetEmail(user.email, rawResetToken).catch(() => {}) } export async function resetPassword(input: ResetPasswordInput) { const tokenHash = hashToken(input.token) const user = userRepo.getUserByResetTokenHash(tokenHash) if (!user || !user.reset_expires || Date.now() > user.reset_expires) { throw new AppError(400, 'Invalid or expired reset token') } const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS) // Bump token version so tokens from before the reset stop working userRepo.updateUser(user.id, { passwordHash, resetToken: null, resetExpires: null, tokenVersion: (user.token_version ?? 0) + 1, }) } export function deleteUserAccount(userId: number) { const success = userRepo.deleteUser(userId) if (!success) { throw new AppError(404, 'User not found') } }