202 lines
6.4 KiB
TypeScript
202 lines
6.4 KiB
TypeScript
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 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 `
|
|
<p>Hi ${firstName},</p>
|
|
<p>You requested a password reset for your RentalDriveGo workspace account.</p>
|
|
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset your password</a></p>
|
|
<p>This link expires in ${RESET_TOKEN_TTL_MINUTES} minutes. If you did not request this, you can safely ignore this email.</p>
|
|
<p>RentalDriveGo</p>
|
|
`
|
|
}
|
|
|
|
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 = process.env.DASHBOARD_URL ?? 'http://localhost:3001'
|
|
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
|