import { Router, Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import rateLimit from 'express-rate-limit'; import { prisma, bigintToNumber } from '../lib/prisma'; import { authMiddleware } from '../lib/auth'; const router = Router(); const JWT_SECRET = process.env.JWT_SECRET; if (!JWT_SECRET) throw new Error('JWT_SECRET environment variable is required but was not set'); const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; const authRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 attempts per window per IP standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests. Please try again in 15 minutes.' }, }); // POST /api/auth/register router.post('/register', authRateLimiter, async (req: Request, res: Response) => { try { const { email, name, password, inviteToken } = req.body; // ── Gate: allow only with a valid pending invite token ───────────────────── if (!inviteToken) { const setting = await prisma.siteSetting.findUnique({ where: { name: 'registration_enabled' } }); if (setting?.value === 'false') { res.status(403).json({ error: 'Registration is currently disabled. Contact your administrator.' }); return; } } else { // Validate invite token const invite = await prisma.invitation.findUnique({ where: { token: inviteToken } }); if (!invite || invite.status !== 'PENDING' || invite.expiresAt < new Date()) { res.status(400).json({ error: 'Invalid or expired invitation token.' }); return; } if (invite.email !== email) { res.status(400).json({ error: 'Email does not match the invitation.' }); return; } // Persist invite details for after user creation (req as any)._invite = invite; } if (!email || !name || !password) { res.status(400).json({ error: 'email, name, and password are required' }); return; } if (password.length < 6) { res.status(400).json({ error: 'Password must be at least 6 characters' }); return; } const existing = await prisma.user.findUnique({ where: { email } }); if (existing) { res.status(409).json({ error: 'Email already registered' }); return; } const hashed = await bcrypt.hash(password, 12); // Determine globalRole from invite type: // - Workspace invite (type=WORKSPACE, projectId=null) → MEMBER // - Project invite (type=PROJECT, projectId set) → PROJECT_USER const invite: { type?: string; projectId?: string | null } | null = (req as any)._invite ?? null; // projectId=null → workspace invite → MEMBER; projectId set → project invite → PROJECT_USER const globalRole: 'MEMBER' | 'PROJECT_USER' = invite?.projectId === null ? 'MEMBER' : 'PROJECT_USER'; const user = await prisma.user.create({ data: { email, name, password: hashed, globalRole, storageQuota: 524288000, // 500 MB default storageUsed: 0, }, select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, storageQuota: true, storageUsed: true }, }); const token = jwt.sign( { userId: user.id, email: user.email, globalRole: user.globalRole }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN } as jwt.SignOptions ); // Accept any pending invitations for this email const pendingInvites = await prisma.invitation.findMany({ where: { email, status: 'PENDING', expiresAt: { gt: new Date() } }, }); const acceptedProjects: { projectId: string; projectName: string }[] = []; for (const invite of pendingInvites) { // Workspace invite (projectId=null) — no project membership to create if (invite.projectId === null) { await prisma.invitation.update({ where: { id: invite.id }, data: { status: 'ACCEPTED' }, }); continue; } // TypeScript narrowing doesn't carry through prisma queries const pid = invite.projectId; const existingMember = await prisma.projectMember.findFirst({ where: { projectId: pid, userId: user.id }, }); if (!existingMember) { await prisma.projectMember.create({ data: { userId: user.id, projectId: pid, role: invite.role, invitedBy: invite.invitedBy ?? undefined, }, }); const project = await prisma.project.findUnique({ where: { id: pid }, select: { id: true, name: true }, }); if (project) acceptedProjects.push({ projectId: project.id, projectName: project.name }); } await prisma.invitation.update({ where: { id: invite.id }, data: { status: 'ACCEPTED' }, }); } res.cookie('token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000, }); res.status(201).json({ user, token, acceptedProjects, userName: user.name }); } catch (err) { console.error('Register error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // POST /api/auth/login router.post('/login', authRateLimiter, async (req: Request, res: Response) => { try { const { email, password } = req.body; if (!email || !password) { res.status(400).json({ error: 'email and password are required' }); return; } const user = await prisma.user.findUnique({ where: { email } }); if (!user) { res.status(401).json({ error: 'Invalid credentials' }); return; } const valid = await bcrypt.compare(password, user.password); if (!valid) { res.status(401).json({ error: 'Invalid credentials' }); return; } const token = jwt.sign( { userId: user.id, email: user.email, globalRole: user.globalRole }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN } as jwt.SignOptions ); // Auto-accept pending invitations for this email const pendingInvites = await prisma.invitation.findMany({ where: { email: user.email, status: 'PENDING', expiresAt: { gt: new Date() } }, }); const acceptedProjects: { projectId: string; projectName: string }[] = []; for (const invite of pendingInvites) { // Workspace invites (projectId=null) — no project membership to create if (invite.projectId === null) { await prisma.invitation.update({ where: { id: invite.id }, data: { status: 'ACCEPTED' }, }); continue; } // TypeScript narrowing doesn't work through prisma queries — assign to let const pid = invite.projectId; const existingMember = await prisma.projectMember.findFirst({ where: { projectId: pid, userId: user.id }, }); if (!existingMember) { await prisma.projectMember.create({ data: { userId: user.id, projectId: pid, role: invite.role, invitedBy: invite.invitedBy ?? undefined, }, }); const project = await prisma.project.findUnique({ where: { id: pid }, select: { id: true, name: true }, }); if (project) acceptedProjects.push({ projectId: project.id, projectName: project.name }); } await prisma.invitation.update({ where: { id: invite.id }, data: { status: 'ACCEPTED' }, }); } res.cookie('token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000, }); res.json({ user: bigintToNumber({ id: user.id, email: user.email, name: user.name, globalRole: user.globalRole, avatarUrl: user.avatarUrl, storageQuota: user.storageQuota, storageUsed: user.storageUsed ?? 0, }), token, acceptedProjects, }); } catch (err) { console.error('Login error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // POST /api/auth/logout router.post('/logout', (_req: Request, res: Response) => { res.clearCookie('token'); res.json({ message: 'Logged out' }); }); // GET /api/auth/me router.get('/me', authMiddleware, async (req: Request, res: Response) => { try { const user = await prisma.user.findUnique({ where: { id: req.user!.userId }, select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, storageQuota: true, storageUsed: true, }, }); if (!user) { res.status(404).json({ error: 'User not found' }); return; } res.json({ user: bigintToNumber({ ...user, storageUsed: user.storageUsed ?? 0 }) }); } catch (err) { console.error('Me error:', err); res.status(500).json({ error: 'Internal server error' }); } }); export default router;