Add user auth with JWT, switch to bun test
- User table with company_id FK, unique email, role enum - Register/login routes with bcrypt + JWT token generation - Auth plugin with authenticate decorator and role guards - Login uses globally unique email (no company header needed) - Dev-auth plugin kept as fallback when JWT_SECRET not set - Switched from vitest to bun:test (vitest had ESM resolution issues with zod in Bun's module structure) - Upgraded to zod 4 - Added Dockerfile.dev and API service to docker-compose - 8 tests passing (health + auth)
This commit is contained in:
111
packages/backend/src/routes/v1/auth.ts
Normal file
111
packages/backend/src/routes/v1/auth.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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'
|
||||
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/auth/register', 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
|
||||
|
||||
// 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,
|
||||
})
|
||||
|
||||
return reply.status(201).send({ user, token })
|
||||
})
|
||||
|
||||
app.post('/auth/login', 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) {
|
||||
return reply.status(401).send({
|
||||
error: { message: 'Invalid email or password', statusCode: 401 },
|
||||
})
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
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,
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
},
|
||||
token,
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user