import type { FastifyPluginAsync } from 'fastify' import { eq } from 'drizzle-orm' import bcrypt from 'bcryptjs' import { RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema, ForgotPasswordSchema, ResetPasswordSchema } from '@lunarfront/shared/schemas' import { users } from '../../db/schema/users.js' import { companies } from '../../db/schema/stores.js' import { EmailService } from '../../services/email.service.js' const SALT_ROUNDS = 10 export const authRoutes: FastifyPluginAsync = async (app) => { // Rate limit auth routes — 10 attempts per 15 minutes per IP const rateLimitConfig = { config: { rateLimit: { max: 10, timeWindow: '15 minutes', }, }, } app.post('/auth/register', rateLimitConfig, async (request, reply) => { const parsed = RegisterSchema.safeParse(request.body) if (!parsed.success) { return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 }, }) } const { email, password, firstName, lastName, role } = parsed.data // Email is globally unique const existing = await app.db .select({ id: users.id }) .from(users) .where(eq(users.email, email)) .limit(1) if (existing.length > 0) { return reply.status(409).send({ error: { message: 'User with this email already exists', statusCode: 409 }, }) } const passwordHash = await bcrypt.hash(password, SALT_ROUNDS) const [user] = await app.db .insert(users) .values({ email, passwordHash, firstName, lastName, role, }) .returning({ id: users.id, email: users.email, firstName: users.firstName, lastName: users.lastName, role: users.role, createdAt: users.createdAt, }) const token = app.jwt.sign({ id: user.id, role: user.role, }) request.log.info({ userId: user.id, email: user.email }, 'User registered') return reply.status(201).send({ user, token }) }) app.post('/auth/login', rateLimitConfig, async (request, reply) => { const parsed = LoginSchema.safeParse(request.body) if (!parsed.success) { return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 }, }) } const { email, password } = parsed.data // Email is globally unique — company is derived from the user record const [user] = await app.db .select() .from(users) .where(eq(users.email, email)) .limit(1) if (!user) { request.log.warn({ email }, 'Login failed — unknown email') return reply.status(401).send({ error: { message: 'Invalid email or password', statusCode: 401 }, }) } const valid = await bcrypt.compare(password, user.passwordHash) if (!valid) { request.log.warn({ email, userId: user.id }, 'Login failed — wrong password') return reply.status(401).send({ error: { message: 'Invalid email or password', statusCode: 401 }, }) } const token = app.jwt.sign({ id: user.id, role: user.role, }) request.log.info({ userId: user.id, email }, 'User logged in') return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, role: user.role, }, token, }) }) // Change own password app.post('/auth/change-password', { preHandler: [app.authenticate] }, async (request, reply) => { const { currentPassword, newPassword } = request.body as { currentPassword?: string; newPassword?: string } if (!currentPassword || !newPassword) { return reply.status(400).send({ error: { message: 'currentPassword and newPassword are required', statusCode: 400 } }) } if (newPassword.length < 12) { return reply.status(400).send({ error: { message: 'Password must be at least 12 characters', statusCode: 400 } }) } const [user] = await app.db.select().from(users).where(eq(users.id, request.user.id)).limit(1) if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) const valid = await bcrypt.compare(currentPassword, user.passwordHash) if (!valid) { return reply.status(401).send({ error: { message: 'Current password is incorrect', statusCode: 401 } }) } const newHash = await bcrypt.hash(newPassword, SALT_ROUNDS) await app.db.update(users).set({ passwordHash: newHash, updatedAt: new Date() }).where(eq(users.id, request.user.id)) request.log.info({ userId: request.user.id }, 'Password changed') return reply.send({ message: 'Password changed' }) }) // Admin: generate password reset token for a user app.post('/auth/reset-password/:userId', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => { const { userId } = request.params as { userId: string } const [user] = await app.db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1) if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '4h' }) const resetLink = `${process.env.APP_URL ?? 'http://localhost:5173'}/reset-password?token=${resetToken}` request.log.info({ userId, generatedBy: request.user.id }, 'Password reset link generated') return reply.send({ resetLink, expiresIn: '4 hours' }) }) // Reset password with token app.post('/auth/reset-password', async (request, reply) => { const parsed = ResetPasswordSchema.safeParse(request.body) if (!parsed.success) { return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) } const { token, newPassword } = parsed.data try { const payload = app.jwt.verify<{ userId: string; purpose: string }>(token) if (payload.purpose !== 'password-reset') { return reply.status(400).send({ error: { message: 'Invalid reset token', statusCode: 400 } }) } const newHash = await bcrypt.hash(newPassword, SALT_ROUNDS) await app.db.update(users).set({ passwordHash: newHash, updatedAt: new Date() }).where(eq(users.id, payload.userId)) request.log.info({ userId: payload.userId }, 'Password reset via token') return reply.send({ message: 'Password reset successfully' }) } catch { return reply.status(400).send({ error: { message: 'Invalid or expired reset token', statusCode: 400 } }) } }) // Forgot password / resend welcome — public, always returns success (no user enumeration) // Pass ?type=welcome for welcome emails, defaults to reset app.post('/auth/forgot-password', rateLimitConfig, async (request, reply) => { const parsed = ForgotPasswordSchema.safeParse(request.body) if (!parsed.success) { return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) } const { email } = parsed.data const isWelcome = (request.query as { type?: string }).type === 'welcome' // Rate limit per email — max 3 emails per hour const emailKey = `pwd-reset:${email.toLowerCase()}` const count = await app.redis.incr(emailKey) if (count === 1) await app.redis.expire(emailKey, 3600) if (count > 3) { return reply.send({ message: 'If an account exists with that email, you will receive an email.' }) } // Always return success — don't reveal whether the email exists const [user] = await app.db.select({ id: users.id, firstName: users.firstName }).from(users).where(eq(users.email, email)).limit(1) if (user) { try { const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '4h' }) const appUrl = process.env.APP_URL ?? 'http://localhost:5173' const resetLink = `${appUrl}/reset-password?token=${resetToken}` const [store] = await app.db.select({ name: companies.name }).from(companies).limit(1) const storeName = store?.name ?? 'LunarFront' if (isWelcome) { await EmailService.send(app.db, { to: email, subject: `Welcome to ${storeName} — Set your password`, html: `

${storeName}

Hi ${user.firstName},

Your account has been created. Click the button below to set your password and get started:

Set Your Password

This link expires in 4 hours. If it expires, you can request a new one from the login page.


Powered by LunarFront

`, text: `Hi ${user.firstName}, welcome to ${storeName}! Set your password here: ${resetLink} — This link expires in 4 hours.`, }) } else { await EmailService.send(app.db, { to: email, subject: `Reset your password — ${storeName}`, html: `

${storeName}

Hi ${user.firstName},

We received a request to reset your password. Click the button below to choose a new one:

Reset Password

This link expires in 4 hours. If you didn't request this, you can safely ignore this email.


Powered by LunarFront

`, text: `Hi ${user.firstName}, reset your password here: ${resetLink} — This link expires in 4 hours.`, }) } request.log.info({ userId: user.id, type: isWelcome ? 'welcome' : 'reset' }, 'Password email sent') } catch (err) { request.log.error({ email, error: (err as Error).message }, 'Failed to send password email') } } return reply.send({ message: 'If an account exists with that email, you will receive a password reset link.' }) }) // Get current user profile app.get('/auth/me', { preHandler: [app.authenticate] }, async (request, reply) => { const [user] = await app.db .select({ id: users.id, email: users.email, firstName: users.firstName, lastName: users.lastName, role: users.role, createdAt: users.createdAt, }) .from(users) .where(eq(users.id, request.user.id)) .limit(1) if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) return reply.send(user) }) // Update current user profile app.patch('/auth/me', { preHandler: [app.authenticate] }, async (request, reply) => { const { firstName, lastName } = request.body as { firstName?: string; lastName?: string } const updates: Record = { updatedAt: new Date() } if (firstName) updates.firstName = firstName if (lastName) updates.lastName = lastName const [user] = await app.db .update(users) .set(updates) .where(eq(users.id, request.user.id)) .returning({ id: users.id, email: users.email, firstName: users.firstName, lastName: users.lastName, role: users.role, }) return reply.send(user) }) // PIN login — for POS unlock, no JWT required to call app.post('/auth/pin-login', rateLimitConfig, async (request, reply) => { const parsed = PinLoginSchema.safeParse(request.body) if (!parsed.success) { return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) } const { code } = parsed.data // First 4 digits = employee number, rest = PIN const employeeNumber = code.slice(0, 4) const pin = code.slice(4) if (!pin) { return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } }) } const [user] = await app.db .select() .from(users) .where(eq(users.employeeNumber, employeeNumber)) .limit(1) if (!user || !user.isActive || !user.pinHash) { return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } }) } const match = await bcrypt.compare(pin, user.pinHash) if (!match) { return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } }) } const token = app.jwt.sign({ id: user.id, role: user.role }, { expiresIn: '8h' }) request.log.info({ userId: user.id }, 'PIN login') return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, role: user.role, }, token, }) }) // Set PIN — requires full auth app.post('/auth/set-pin', { preHandler: [app.authenticate] }, async (request, reply) => { const parsed = SetPinSchema.safeParse(request.body) if (!parsed.success) { return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) } const pinHash = await bcrypt.hash(parsed.data.pin, SALT_ROUNDS) await app.db.update(users).set({ pinHash, updatedAt: new Date() }).where(eq(users.id, request.user.id)) request.log.info({ userId: request.user.id }, 'POS PIN set') return reply.send({ message: 'PIN set' }) }) // Remove PIN — requires full auth app.delete('/auth/pin', { preHandler: [app.authenticate] }, async (request, reply) => { await app.db.update(users).set({ pinHash: null, updatedAt: new Date() }).where(eq(users.id, request.user.id)) request.log.info({ userId: request.user.id }, 'POS PIN removed') return reply.send({ message: 'PIN removed' }) }) }