| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- 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;
|