Files
lunarfront-app/packages/backend/src/routes/v1/auth.ts
Ryan Moon b9f78639e2 Add paginated users/roles, user status, frontend permissions, profile pictures, identifier file storage
- Users page: paginated, searchable, sortable with inline roles (no N+1)
- Roles page: paginated, searchable, sortable + /roles/all for dropdowns
- User is_active field with migration, PATCH toggle, auth check (disabled=401)
- Frontend permission checks: auth store loads permissions, sidebar/buttons conditional
- Profile pictures via file storage for users and members, avatar component
- Identifier images use file storage API instead of base64
- Fix TypeScript errors across admin UI
- 64 API tests passing (10 new)
2026-03-29 08:16:34 -05:00

254 lines
8.6 KiB
TypeScript

import type { FastifyPluginAsync } from 'fastify'
import { eq } from 'drizzle-orm'
import bcrypt from 'bcrypt'
import { RegisterSchema, LoginSchema } from '@forte/shared/schemas'
import { users } from '../../db/schema/users.js'
import { companies } from '../../db/schema/stores.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
const companyId = request.companyId
// Validate that the company exists
if (!companyId) {
return reply.status(400).send({
error: { message: 'Company ID is required (x-company-id header)', statusCode: 400 },
})
}
const [company] = await app.db
.select({ id: companies.id })
.from(companies)
.where(eq(companies.id, companyId))
.limit(1)
if (!company) {
return reply.status(400).send({
error: { message: 'Invalid company', statusCode: 400 },
})
}
// Email is globally unique across all companies
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({
companyId,
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,
companyId,
role: user.role,
})
request.log.info({ userId: user.id, email: user.email, companyId }, '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,
companyId: user.companyId,
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 } })
// Generate a signed reset token that expires in 1 hour
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '1h' })
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: '1 hour' })
})
// Reset password with token
app.post('/auth/reset-password', async (request, reply) => {
const { token, newPassword } = request.body as { token?: string; newPassword?: string }
if (!token || !newPassword) {
return reply.status(400).send({ error: { message: 'token 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 } })
}
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 } })
}
})
// 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<string, unknown> = { 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)
})
}