auth.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import { Router, Request, Response } from 'express';
  2. import bcrypt from 'bcryptjs';
  3. import jwt from 'jsonwebtoken';
  4. import rateLimit from 'express-rate-limit';
  5. import { prisma, bigintToNumber } from '../lib/prisma';
  6. import { authMiddleware } from '../lib/auth';
  7. const router = Router();
  8. const JWT_SECRET = process.env.JWT_SECRET;
  9. if (!JWT_SECRET) throw new Error('JWT_SECRET environment variable is required but was not set');
  10. const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
  11. const authRateLimiter = rateLimit({
  12. windowMs: 15 * 60 * 1000, // 15 minutes
  13. max: 5, // 5 attempts per window per IP
  14. standardHeaders: true,
  15. legacyHeaders: false,
  16. message: { error: 'Too many requests. Please try again in 15 minutes.' },
  17. });
  18. // POST /api/auth/register
  19. router.post('/register', authRateLimiter, async (req: Request, res: Response) => {
  20. try {
  21. const { email, name, password, inviteToken } = req.body;
  22. // ── Gate: allow only with a valid pending invite token ─────────────────────
  23. if (!inviteToken) {
  24. const setting = await prisma.siteSetting.findUnique({ where: { name: 'registration_enabled' } });
  25. if (setting?.value === 'false') {
  26. res.status(403).json({ error: 'Registration is currently disabled. Contact your administrator.' });
  27. return;
  28. }
  29. } else {
  30. // Validate invite token
  31. const invite = await prisma.invitation.findUnique({ where: { token: inviteToken } });
  32. if (!invite || invite.status !== 'PENDING' || invite.expiresAt < new Date()) {
  33. res.status(400).json({ error: 'Invalid or expired invitation token.' });
  34. return;
  35. }
  36. if (invite.email !== email) {
  37. res.status(400).json({ error: 'Email does not match the invitation.' });
  38. return;
  39. }
  40. // Persist invite details for after user creation
  41. (req as any)._invite = invite;
  42. }
  43. if (!email || !name || !password) {
  44. res.status(400).json({ error: 'email, name, and password are required' });
  45. return;
  46. }
  47. if (password.length < 6) {
  48. res.status(400).json({ error: 'Password must be at least 6 characters' });
  49. return;
  50. }
  51. const existing = await prisma.user.findUnique({ where: { email } });
  52. if (existing) {
  53. res.status(409).json({ error: 'Email already registered' });
  54. return;
  55. }
  56. const hashed = await bcrypt.hash(password, 12);
  57. // Determine globalRole from invite type:
  58. // - Workspace invite (type=WORKSPACE, projectId=null) → MEMBER
  59. // - Project invite (type=PROJECT, projectId set) → PROJECT_USER
  60. const invite: { type?: string; projectId?: string | null } | null = (req as any)._invite ?? null;
  61. // projectId=null → workspace invite → MEMBER; projectId set → project invite → PROJECT_USER
  62. const globalRole: 'MEMBER' | 'PROJECT_USER' = invite?.projectId === null ? 'MEMBER' : 'PROJECT_USER';
  63. const user = await prisma.user.create({
  64. data: {
  65. email,
  66. name,
  67. password: hashed,
  68. globalRole,
  69. storageQuota: 524288000, // 500 MB default
  70. storageUsed: 0,
  71. },
  72. select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, storageQuota: true, storageUsed: true },
  73. });
  74. const token = jwt.sign(
  75. { userId: user.id, email: user.email, globalRole: user.globalRole },
  76. JWT_SECRET,
  77. { expiresIn: JWT_EXPIRES_IN } as jwt.SignOptions
  78. );
  79. // Accept any pending invitations for this email
  80. const pendingInvites = await prisma.invitation.findMany({
  81. where: { email, status: 'PENDING', expiresAt: { gt: new Date() } },
  82. });
  83. const acceptedProjects: { projectId: string; projectName: string }[] = [];
  84. for (const invite of pendingInvites) {
  85. // Workspace invite (projectId=null) — no project membership to create
  86. if (invite.projectId === null) {
  87. await prisma.invitation.update({
  88. where: { id: invite.id },
  89. data: { status: 'ACCEPTED' },
  90. });
  91. continue;
  92. }
  93. // TypeScript narrowing doesn't carry through prisma queries
  94. const pid = invite.projectId;
  95. const existingMember = await prisma.projectMember.findFirst({
  96. where: { projectId: pid, userId: user.id },
  97. });
  98. if (!existingMember) {
  99. await prisma.projectMember.create({
  100. data: {
  101. userId: user.id,
  102. projectId: pid,
  103. role: invite.role,
  104. invitedBy: invite.invitedBy ?? undefined,
  105. },
  106. });
  107. const project = await prisma.project.findUnique({
  108. where: { id: pid },
  109. select: { id: true, name: true },
  110. });
  111. if (project) acceptedProjects.push({ projectId: project.id, projectName: project.name });
  112. }
  113. await prisma.invitation.update({
  114. where: { id: invite.id },
  115. data: { status: 'ACCEPTED' },
  116. });
  117. }
  118. res.cookie('token', token, {
  119. httpOnly: true,
  120. secure: process.env.NODE_ENV === 'production',
  121. sameSite: 'lax',
  122. maxAge: 7 * 24 * 60 * 60 * 1000,
  123. });
  124. res.status(201).json({ user, token, acceptedProjects, userName: user.name });
  125. } catch (err) {
  126. console.error('Register error:', err);
  127. res.status(500).json({ error: 'Internal server error' });
  128. }
  129. });
  130. // POST /api/auth/login
  131. router.post('/login', authRateLimiter, async (req: Request, res: Response) => {
  132. try {
  133. const { email, password } = req.body;
  134. if (!email || !password) {
  135. res.status(400).json({ error: 'email and password are required' });
  136. return;
  137. }
  138. const user = await prisma.user.findUnique({ where: { email } });
  139. if (!user) {
  140. res.status(401).json({ error: 'Invalid credentials' });
  141. return;
  142. }
  143. const valid = await bcrypt.compare(password, user.password);
  144. if (!valid) {
  145. res.status(401).json({ error: 'Invalid credentials' });
  146. return;
  147. }
  148. const token = jwt.sign(
  149. { userId: user.id, email: user.email, globalRole: user.globalRole },
  150. JWT_SECRET,
  151. { expiresIn: JWT_EXPIRES_IN } as jwt.SignOptions
  152. );
  153. // Auto-accept pending invitations for this email
  154. const pendingInvites = await prisma.invitation.findMany({
  155. where: { email: user.email, status: 'PENDING', expiresAt: { gt: new Date() } },
  156. });
  157. const acceptedProjects: { projectId: string; projectName: string }[] = [];
  158. for (const invite of pendingInvites) {
  159. // Workspace invites (projectId=null) — no project membership to create
  160. if (invite.projectId === null) {
  161. await prisma.invitation.update({
  162. where: { id: invite.id },
  163. data: { status: 'ACCEPTED' },
  164. });
  165. continue;
  166. }
  167. // TypeScript narrowing doesn't work through prisma queries — assign to let
  168. const pid = invite.projectId;
  169. const existingMember = await prisma.projectMember.findFirst({
  170. where: { projectId: pid, userId: user.id },
  171. });
  172. if (!existingMember) {
  173. await prisma.projectMember.create({
  174. data: {
  175. userId: user.id,
  176. projectId: pid,
  177. role: invite.role,
  178. invitedBy: invite.invitedBy ?? undefined,
  179. },
  180. });
  181. const project = await prisma.project.findUnique({
  182. where: { id: pid },
  183. select: { id: true, name: true },
  184. });
  185. if (project) acceptedProjects.push({ projectId: project.id, projectName: project.name });
  186. }
  187. await prisma.invitation.update({
  188. where: { id: invite.id },
  189. data: { status: 'ACCEPTED' },
  190. });
  191. }
  192. res.cookie('token', token, {
  193. httpOnly: true,
  194. secure: process.env.NODE_ENV === 'production',
  195. sameSite: 'lax',
  196. maxAge: 7 * 24 * 60 * 60 * 1000,
  197. });
  198. res.json({
  199. user: bigintToNumber({
  200. id: user.id,
  201. email: user.email,
  202. name: user.name,
  203. globalRole: user.globalRole,
  204. avatarUrl: user.avatarUrl,
  205. storageQuota: user.storageQuota,
  206. storageUsed: user.storageUsed ?? 0,
  207. }),
  208. token,
  209. acceptedProjects,
  210. });
  211. } catch (err) {
  212. console.error('Login error:', err);
  213. res.status(500).json({ error: 'Internal server error' });
  214. }
  215. });
  216. // POST /api/auth/logout
  217. router.post('/logout', (_req: Request, res: Response) => {
  218. res.clearCookie('token');
  219. res.json({ message: 'Logged out' });
  220. });
  221. // GET /api/auth/me
  222. router.get('/me', authMiddleware, async (req: Request, res: Response) => {
  223. try {
  224. const user = await prisma.user.findUnique({
  225. where: { id: req.user!.userId },
  226. select: {
  227. id: true, email: true, name: true, globalRole: true, avatarUrl: true,
  228. storageQuota: true, storageUsed: true,
  229. },
  230. });
  231. if (!user) {
  232. res.status(404).json({ error: 'User not found' });
  233. return;
  234. }
  235. res.json({ user: bigintToNumber({ ...user, storageUsed: user.storageUsed ?? 0 }) });
  236. } catch (err) {
  237. console.error('Me error:', err);
  238. res.status(500).json({ error: 'Internal server error' });
  239. }
  240. });
  241. export default router;