users.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. import { Router, Request, Response } from 'express';
  2. import bcrypt from 'bcryptjs';
  3. import multer from 'multer';
  4. import path from 'path';
  5. import { v4 as uuidv4 } from 'uuid';
  6. import { prisma, bigintToNumber } from '../lib/prisma';
  7. import { authMiddleware } from '../lib/auth';
  8. const router = Router();
  9. router.use(authMiddleware);
  10. const str = (v: string | string[] | undefined): string =>
  11. Array.isArray(v) ? v[0] ?? '' : (v ?? '');
  12. // GET /api/users — list all users (admin only, with search)
  13. router.get('/', async (req: Request, res: Response) => {
  14. try {
  15. if (req.user!.globalRole !== 'ADMIN') {
  16. res.status(403).json({ error: 'Forbidden — admin only' });
  17. return;
  18. }
  19. const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
  20. // storageUsed = sum of fileSize of all assets in projects this user owns
  21. const users = await prisma.user.findMany({
  22. where: search ? {
  23. OR: [
  24. { name: { contains: search, mode: 'insensitive' } },
  25. { email: { contains: search, mode: 'insensitive' } },
  26. ],
  27. } : {},
  28. select: {
  29. id: true,
  30. email: true,
  31. name: true,
  32. globalRole: true,
  33. avatarUrl: true,
  34. active: true,
  35. storageQuota: true,
  36. createdAt: true,
  37. // total storage used = sum of assets in projects this user owns
  38. projects: {
  39. select: {
  40. id: true,
  41. assets: {
  42. select: { fileSize: true },
  43. },
  44. },
  45. },
  46. _count: {
  47. select: {
  48. memberships: true,
  49. comments: true,
  50. },
  51. },
  52. },
  53. orderBy: { createdAt: 'desc' },
  54. });
  55. // Compute storageUsed and ownedProjects from owned projects
  56. const usersWithStorage = users.map(u => ({
  57. ...u,
  58. storageUsed: u.projects.reduce(
  59. (sum, p) => sum + p.assets.reduce((s, a) => s + Number(a.fileSize), 0),
  60. 0
  61. ),
  62. ownedProjects: u.projects.length,
  63. }));
  64. res.json({ users: usersWithStorage.map(bigintToNumber) });
  65. } catch (err) {
  66. console.error('List users error:', err);
  67. res.status(500).json({ error: 'Internal server error' });
  68. }
  69. });
  70. // GET /api/users/me — get current user profile
  71. router.get('/me', async (req: Request, res: Response) => {
  72. try {
  73. const user = await prisma.user.findUnique({
  74. where: { id: req.user!.userId },
  75. select: {
  76. id: true,
  77. email: true,
  78. name: true,
  79. globalRole: true,
  80. avatarUrl: true,
  81. active: true,
  82. createdAt: true,
  83. _count: {
  84. select: {
  85. memberships: true,
  86. comments: true,
  87. },
  88. },
  89. },
  90. });
  91. if (!user) {
  92. res.status(404).json({ error: 'User not found' });
  93. return;
  94. }
  95. res.json({ user });
  96. } catch (err) {
  97. console.error('Get me error:', err);
  98. res.status(500).json({ error: 'Internal server error' });
  99. }
  100. });
  101. // PUT /api/users/:id/quota — update storage quota (admin only)
  102. router.put('/:id/quota', async (req: Request, res: Response) => {
  103. try {
  104. if (req.user!.globalRole !== 'ADMIN') {
  105. res.status(403).json({ error: 'Forbidden — admin only' });
  106. return;
  107. }
  108. const { storageQuota } = req.body;
  109. if (typeof storageQuota !== 'number' || !Number.isInteger(storageQuota) || storageQuota < 0) {
  110. res.status(400).json({ error: 'storageQuota must be a non-negative integer (bytes)' });
  111. return;
  112. }
  113. // Allow max 1 TB
  114. const MAX_QUOTA = 1024 * 1024 * 1024 * 1024;
  115. if (storageQuota > MAX_QUOTA) {
  116. res.status(400).json({ error: `storageQuota cannot exceed ${MAX_QUOTA} bytes (1 TB)` });
  117. return;
  118. }
  119. const user = await prisma.user.findUnique({
  120. where: { id: str(req.params.id) },
  121. select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true, storageUsed: true },
  122. });
  123. if (!user) {
  124. res.status(404).json({ error: 'User not found' });
  125. return;
  126. }
  127. await prisma.user.update({
  128. where: { id: str(req.params.id) },
  129. data: { storageQuota },
  130. });
  131. res.json({
  132. user: bigintToNumber({ ...user, storageQuota, storageUsed: user.storageUsed ?? BigInt(0) }),
  133. });
  134. } catch (err) {
  135. console.error('Update quota error:', err);
  136. res.status(500).json({ error: 'Internal server error' });
  137. }
  138. });
  139. // PUT /api/users/me — update current user profile
  140. router.put('/me', async (req: Request, res: Response) => {
  141. try {
  142. const { name, avatarUrl, currentPassword, newPassword } = req.body;
  143. if (newPassword) {
  144. if (!currentPassword) {
  145. res.status(400).json({ error: 'currentPassword is required to change password' });
  146. return;
  147. }
  148. if (newPassword.length < 6) {
  149. res.status(400).json({ error: 'New password must be at least 6 characters' });
  150. return;
  151. }
  152. const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
  153. if (!user) {
  154. res.status(404).json({ error: 'User not found' });
  155. return;
  156. }
  157. const valid = await bcrypt.compare(currentPassword, user.password);
  158. if (!valid) {
  159. res.status(401).json({ error: 'Current password is incorrect' });
  160. return;
  161. }
  162. const hashed = await bcrypt.hash(newPassword, 12);
  163. const updated = await prisma.user.update({
  164. where: { id: req.user!.userId },
  165. data: {
  166. name: name?.trim() || undefined,
  167. avatarUrl: avatarUrl ?? undefined,
  168. password: hashed,
  169. },
  170. select: {
  171. id: true,
  172. email: true,
  173. name: true,
  174. globalRole: true,
  175. avatarUrl: true,
  176. active: true,
  177. },
  178. });
  179. res.json({ user: updated });
  180. return;
  181. }
  182. const updated = await prisma.user.update({
  183. where: { id: req.user!.userId },
  184. data: {
  185. name: name?.trim() || undefined,
  186. avatarUrl: avatarUrl ?? undefined,
  187. },
  188. select: {
  189. id: true,
  190. email: true,
  191. name: true,
  192. globalRole: true,
  193. avatarUrl: true,
  194. active: true,
  195. },
  196. });
  197. res.json({ user: updated });
  198. } catch (err) {
  199. console.error('Update me error:', err);
  200. res.status(500).json({ error: 'Internal server error' });
  201. }
  202. });
  203. // ── Avatar upload ────────────────────────────────────────────────────────────
  204. const avatarStorage = multer.diskStorage({
  205. destination: (_req, _file, cb) => {
  206. cb(null, path.resolve(__dirname, '../../uploads/avatars'));
  207. },
  208. filename: (_req, file, cb) => {
  209. const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
  210. cb(null, `${uuidv4()}${ext}`);
  211. },
  212. });
  213. const avatarUpload = multer({
  214. storage: avatarStorage,
  215. limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
  216. fileFilter: (_req, file, cb) => {
  217. if (['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.mimetype)) {
  218. cb(null, true);
  219. } else {
  220. cb(new Error('Only JPEG, PNG, WebP, or GIF images are allowed'));
  221. }
  222. },
  223. });
  224. // POST /api/users/me/avatar — upload avatar image
  225. router.post('/me/avatar', avatarUpload.single('avatar'), async (req: Request, res: Response) => {
  226. try {
  227. if (!req.file) {
  228. res.status(400).json({ error: 'No file uploaded' });
  229. return;
  230. }
  231. const avatarUrl = `/uploads/avatars/${req.file!.filename}`;
  232. const updated = await prisma.user.update({
  233. where: { id: req.user!.userId },
  234. data: { avatarUrl },
  235. select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true },
  236. });
  237. res.json({ user: updated, avatarUrl });
  238. } catch (err) {
  239. console.error('Avatar upload error:', err);
  240. res.status(500).json({ error: 'Internal server error' });
  241. }
  242. });
  243. // PUT /api/users/:id/role — change user globalRole (admin only)
  244. router.put('/:id/role', async (req: Request, res: Response) => {
  245. try {
  246. if (req.user!.globalRole !== 'ADMIN') {
  247. res.status(403).json({ error: 'Forbidden — admin only' });
  248. return;
  249. }
  250. const { role } = req.body;
  251. const validRoles = ['ADMIN', 'MEMBER', 'PROJECT_USER'];
  252. if (!validRoles.includes(role)) {
  253. res.status(400).json({ error: 'Invalid globalRole. Must be ADMIN, MEMBER, or PROJECT_USER' });
  254. return;
  255. }
  256. const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
  257. if (!user) {
  258. res.status(404).json({ error: 'User not found' });
  259. return;
  260. }
  261. // Prevent last admin from being demoted
  262. if (role !== 'ADMIN') {
  263. const adminCount = await prisma.user.count({ where: { globalRole: 'ADMIN', active: true } });
  264. if (adminCount <= 1 && user.globalRole === 'ADMIN') {
  265. res.status(400).json({ error: 'Cannot demote the last system admin' });
  266. return;
  267. }
  268. }
  269. const updated = await prisma.user.update({
  270. where: { id: str(req.params.id) },
  271. data: { globalRole: role as any },
  272. select: {
  273. id: true,
  274. email: true,
  275. name: true,
  276. globalRole: true,
  277. avatarUrl: true,
  278. active: true,
  279. createdAt: true,
  280. },
  281. });
  282. res.json({ user: updated });
  283. } catch (err) {
  284. console.error('Update globalRole error:', err);
  285. res.status(500).json({ error: 'Internal server error' });
  286. }
  287. });
  288. // PUT /api/users/:id/active — activate/deactivate user (admin only)
  289. router.put('/:id/active', async (req: Request, res: Response) => {
  290. try {
  291. if (req.user!.globalRole !== 'ADMIN') {
  292. res.status(403).json({ error: 'Forbidden — admin only' });
  293. return;
  294. }
  295. const { active } = req.body;
  296. if (typeof active !== 'boolean') {
  297. res.status(400).json({ error: 'active must be a boolean' });
  298. return;
  299. }
  300. const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
  301. if (!user) {
  302. res.status(404).json({ error: 'User not found' });
  303. return;
  304. }
  305. if (user.id === req.user!.userId) {
  306. res.status(400).json({ error: 'Cannot deactivate your own account' });
  307. return;
  308. }
  309. if (!active && user.globalRole === 'ADMIN') {
  310. const adminCount = await prisma.user.count({ where: { globalRole: 'ADMIN', active: true } });
  311. if (adminCount <= 1) {
  312. res.status(400).json({ error: 'Cannot deactivate the last admin' });
  313. return;
  314. }
  315. }
  316. const updated = await prisma.user.update({
  317. where: { id: str(req.params.id) },
  318. data: { active },
  319. select: {
  320. id: true,
  321. email: true,
  322. name: true,
  323. globalRole: true,
  324. avatarUrl: true,
  325. active: true,
  326. createdAt: true,
  327. },
  328. });
  329. res.json({ user: updated });
  330. } catch (err) {
  331. console.error('Update active error:', err);
  332. res.status(500).json({ error: 'Internal server error' });
  333. }
  334. });
  335. // DELETE /api/users/:id — delete user (admin only)
  336. router.delete('/:id', async (req: Request, res: Response) => {
  337. try {
  338. if (req.user!.globalRole !== 'ADMIN') {
  339. res.status(403).json({ error: 'Forbidden — admin only' });
  340. return;
  341. }
  342. const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
  343. if (!user) {
  344. res.status(404).json({ error: 'User not found' });
  345. return;
  346. }
  347. if (user.id === req.user!.userId) {
  348. res.status(400).json({ error: 'Cannot delete your own account' });
  349. return;
  350. }
  351. await prisma.user.delete({ where: { id: str(req.params.id) } });
  352. res.json({ message: 'User deleted' });
  353. } catch (err) {
  354. console.error('Delete user error:', err);
  355. res.status(500).json({ error: 'Internal server error' });
  356. }
  357. });
  358. export default router;