import bcrypt from 'bcryptjs' import crypto from 'crypto' import jwt from 'jsonwebtoken' import { AppError } from '../../http/errors' import { sendTransactionalEmail } from '../../services/notificationService' import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations' import { presentEmployeeSession } from './auth.presenter' import * as repo from './auth.employee.repo' import { employeeLanguageSchema, employeeLoginSchema } from './auth.employee.schemas' import type { output } from 'zod' const RESET_TOKEN_TTL_MINUTES = 60 type EmployeeLoginInput = output type EmployeeLanguageInput = output function signEmployeeToken(employeeId: string) { return jwt.sign( { sub: employeeId, type: 'employee' }, process.env.JWT_SECRET!, { expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'] }, ) } function ensureDashboardBasePath(baseUrl: string) { try { const url = new URL(baseUrl) const pathname = url.pathname.replace(/\/$/, '') if (!pathname.endsWith('/dashboard')) url.pathname = `${pathname}/dashboard` return url.toString().replace(/\/$/, '') } catch { const trimmed = baseUrl.replace(/\/$/, '') return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard` } } export async function getMe(employeeId: string) { const employee = await repo.findEmployeeWithCompanyById(employeeId) if (!employee || !employee.isActive) { throw new AppError('Employee account not found or inactive', 401, 'unauthenticated') } return presentEmployeeSession(employee) } export async function login(body: EmployeeLoginInput) { const employee = await repo.findEmployeeWithCompanyByEmail(body.email) if (!employee || !employee.isActive) { throw new AppError('Invalid email or password', 401, 'invalid_credentials') } if (!employee.passwordHash) { throw new AppError('This account does not have a password yet. Use the invitation or reset email to set one first.', 401, 'password_not_set') } const validPassword = await bcrypt.compare(body.password, employee.passwordHash) if (!validPassword) { throw new AppError('Invalid email or password', 401, 'invalid_credentials') } return presentEmployeeSession(employee, signEmployeeToken(employee.id)) } export async function forgotPassword(email: string) { const employee = await repo.findActiveEmployeeByEmail(email) if (employee) { const rawToken = crypto.randomBytes(32).toString('hex') const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000) await repo.setPasswordResetToken(employee.id, rawToken, expiresAt) const dashboardUrl = ensureDashboardBasePath( process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard', ) const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}` const lang = (employee.preferredLanguage as Lang) ?? 'fr' await sendTransactionalEmail({ to: employee.email, subject: resetPasswordEmail.subject(lang), html: resetPasswordEmail.html(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang), text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang), }).catch((err) => { console.error('[ForgotPassword] Email delivery failed:', err?.message) console.error('[ForgotPassword] Provider config — resendKey present:', !!process.env.RESEND_API_KEY, '| smtpHost:', process.env.MAIL_HOST ?? 'not set') }) } return { message: 'If that email is registered, a reset link has been sent.' } } export async function updateLanguage(employeeId: string, language: EmployeeLanguageInput['language']) { await repo.updatePreferredLanguage(employeeId, language) return { language } } export async function resetPassword(token: string, password: string) { const employee = await repo.findEmployeeByResetToken(token) if (!employee) { throw new AppError('Reset link is invalid or has expired.', 400, 'invalid_token') } await repo.resetPassword(employee.id, await bcrypt.hash(password, 12)) return { message: 'Password updated successfully. You can now sign in.' } }