import { Router } from 'express' import { z } from 'zod' import bcrypt from 'bcryptjs' import jwt from 'jsonwebtoken' import crypto from 'crypto' import { prisma } from '../lib/prisma' import { sendTransactionalEmail } from '../services/notificationService' const router = Router() const RESET_TOKEN_TTL_MINUTES = 60 function ensureAppBasePath(baseUrl: string, basePath: string) { try { const url = new URL(baseUrl) const normalizedPathname = url.pathname.replace(/\/$/, '') if (normalizedPathname === basePath || normalizedPathname.endsWith(basePath)) { return url.toString().replace(/\/$/, '') } url.pathname = `${normalizedPathname}${basePath}` || basePath return url.toString().replace(/\/$/, '') } catch { const trimmed = baseUrl.replace(/\/$/, '') return trimmed.endsWith(basePath) ? trimmed : `${trimmed}${basePath}` } } 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 buildResetEmailHtml(resetUrl: string, firstName: string) { return `
Hi ${firstName},
You requested a password reset for your RentalDriveGo workspace account.
This link expires in ${RESET_TOKEN_TTL_MINUTES} minutes. If you did not request this, you can safely ignore this email.
RentalDriveGo
` } router.get('/me', async (req, res, next) => { try { const authHeader = req.headers.authorization const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null if (!token) { return res.status(401).json({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401, }) } let employeeId = '' try { const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string } if (payload.type !== 'employee') { return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401, }) } employeeId = payload.sub } catch { return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401, }) } const employee = await prisma.employee.findUnique({ where: { id: employeeId }, include: { company: true }, }) if (!employee || !employee.isActive) { return res.status(401).json({ error: 'unauthenticated', message: 'Employee account not found or inactive', statusCode: 401, }) } res.json({ data: { employee: { id: employee.id, email: employee.email, firstName: employee.firstName, lastName: employee.lastName, role: employee.role, companyId: employee.companyId, companyName: employee.company.name, companySlug: employee.company.slug, }, }, }) } catch (err) { next(err) } }) router.post('/login', async (req, res, next) => { try { const { email, password } = z.object({ email: z.string().email().max(255).trim().toLowerCase(), password: z.string().max(128), }).parse(req.body) const employee = await prisma.employee.findFirst({ where: { email }, include: { company: true }, }) if (!employee || !employee.isActive) { return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) } if (!employee.passwordHash) { return res.status(401).json({ error: 'password_not_set', message: 'This account does not have a password yet. Use the invitation or reset email to set one first.', statusCode: 401 }) } const valid = await bcrypt.compare(password, employee.passwordHash) if (!valid) { return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) } const token = signEmployeeToken(employee.id) res.json({ data: { token, employee: { id: employee.id, email: employee.email, firstName: employee.firstName, lastName: employee.lastName, role: employee.role, companyId: employee.companyId, companyName: employee.company.name, companySlug: employee.company.slug, }, }, }) } catch (err) { next(err) } }) router.post('/forgot-password', async (req, res, next) => { try { const { email } = z.object({ email: z.string().email().max(255).trim().toLowerCase() }).parse(req.body) // Always return success to avoid user enumeration const employee = await prisma.employee.findFirst({ where: { email, isActive: true } }) if (employee) { const rawToken = crypto.randomBytes(32).toString('hex') const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000) await prisma.employee.update({ where: { id: employee.id }, data: { passwordResetToken: rawToken, passwordResetExpiresAt: expiresAt }, }) const dashboardUrl = ensureAppBasePath( process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard', '/dashboard', ) const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}` await sendTransactionalEmail({ to: email, subject: 'Reset your RentalDriveGo password', html: buildResetEmailHtml(resetUrl, employee.firstName), text: `Hi ${employee.firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${RESET_TOKEN_TTL_MINUTES} minutes.\n\nRentalDriveGo`, }).catch((err) => console.error('[ForgotPassword] Email send failed:', err?.message)) } res.json({ data: { message: 'If that email is registered, a reset link has been sent.' } }) } catch (err) { next(err) } }) router.post('/reset-password', async (req, res, next) => { try { const { token, password } = z.object({ token: z.string().min(1), password: z.string().min(8).max(128), }).parse(req.body) const employee = await prisma.employee.findFirst({ where: { passwordResetToken: token, passwordResetExpiresAt: { gt: new Date() }, }, }) if (!employee) { return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 }) } const passwordHash = await bcrypt.hash(password, 12) await prisma.employee.update({ where: { id: employee.id }, data: { passwordHash, passwordResetToken: null, passwordResetExpiresAt: null, }, }) res.json({ data: { message: 'Password updated successfully. You can now sign in.' } }) } catch (err) { next(err) } }) export default router