109 lines
4.1 KiB
TypeScript
109 lines
4.1 KiB
TypeScript
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<typeof employeeLoginSchema>
|
|
type EmployeeLanguageInput = output<typeof employeeLanguageSchema>
|
|
|
|
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.' }
|
|
}
|