users.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  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 } 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 + a.fileSize, 0),
  60. 0
  61. ),
  62. ownedProjects: u.projects.length,
  63. }));
  64. res.json({ users: usersWithStorage });
  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: {
  133. ...user,
  134. storageQuota,
  135. storageUsed: user.storageUsed ?? 0,
  136. },
  137. });
  138. } catch (err) {
  139. console.error('Update quota error:', err);
  140. res.status(500).json({ error: 'Internal server error' });
  141. }
  142. });
  143. // PUT /api/users/me — update current user profile
  144. router.put('/me', async (req: Request, res: Response) => {
  145. try {
  146. const { name, avatarUrl, currentPassword, newPassword } = req.body;
  147. if (newPassword) {
  148. if (!currentPassword) {
  149. res.status(400).json({ error: 'currentPassword is required to change password' });
  150. return;
  151. }
  152. if (newPassword.length < 6) {
  153. res.status(400).json({ error: 'New password must be at least 6 characters' });
  154. return;
  155. }
  156. const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
  157. if (!user) {
  158. res.status(404).json({ error: 'User not found' });
  159. return;
  160. }
  161. const valid = await bcrypt.compare(currentPassword, user.password);
  162. if (!valid) {
  163. res.status(401).json({ error: 'Current password is incorrect' });
  164. return;
  165. }
  166. const hashed = await bcrypt.hash(newPassword, 12);
  167. const updated = await prisma.user.update({
  168. where: { id: req.user!.userId },
  169. data: {
  170. name: name?.trim() || undefined,
  171. avatarUrl: avatarUrl ?? undefined,
  172. password: hashed,
  173. },
  174. select: {
  175. id: true,
  176. email: true,
  177. name: true,
  178. globalRole: true,
  179. avatarUrl: true,
  180. active: true,
  181. },
  182. });
  183. res.json({ user: updated });
  184. return;
  185. }
  186. const updated = await prisma.user.update({
  187. where: { id: req.user!.userId },
  188. data: {
  189. name: name?.trim() || undefined,
  190. avatarUrl: avatarUrl ?? undefined,
  191. },
  192. select: {
  193. id: true,
  194. email: true,
  195. name: true,
  196. globalRole: true,
  197. avatarUrl: true,
  198. active: true,
  199. },
  200. });
  201. res.json({ user: updated });
  202. } catch (err) {
  203. console.error('Update me error:', err);
  204. res.status(500).json({ error: 'Internal server error' });
  205. }
  206. });
  207. // ── Avatar upload ────────────────────────────────────────────────────────────
  208. const avatarStorage = multer.diskStorage({
  209. destination: (_req, _file, cb) => {
  210. cb(null, path.resolve(__dirname, '../../uploads/avatars'));
  211. },
  212. filename: (_req, file, cb) => {
  213. const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
  214. cb(null, `${uuidv4()}${ext}`);
  215. },
  216. });
  217. const avatarUpload = multer({
  218. storage: avatarStorage,
  219. limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
  220. fileFilter: (_req, file, cb) => {
  221. if (['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.mimetype)) {
  222. cb(null, true);
  223. } else {
  224. cb(new Error('Only JPEG, PNG, WebP, or GIF images are allowed'));
  225. }
  226. },
  227. });
  228. // POST /api/users/me/avatar — upload avatar image
  229. router.post('/me/avatar', avatarUpload.single('avatar'), async (req: Request, res: Response) => {
  230. try {
  231. if (!req.file) {
  232. res.status(400).json({ error: 'No file uploaded' });
  233. return;
  234. }
  235. const avatarUrl = `/uploads/avatars/${req.file!.filename}`;
  236. const updated = await prisma.user.update({
  237. where: { id: req.user!.userId },
  238. data: { avatarUrl },
  239. select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true },
  240. });
  241. res.json({ user: updated, avatarUrl });
  242. } catch (err) {
  243. console.error('Avatar upload error:', err);
  244. res.status(500).json({ error: 'Internal server error' });
  245. }
  246. });
  247. // PUT /api/users/:id/role — change user globalRole (admin only)
  248. router.put('/:id/role', async (req: Request, res: Response) => {
  249. try {
  250. if (req.user!.globalRole !== 'ADMIN') {
  251. res.status(403).json({ error: 'Forbidden — admin only' });
  252. return;
  253. }
  254. const { role } = req.body;
  255. const validRoles = ['ADMIN', 'MEMBER', 'PROJECT_USER'];
  256. if (!validRoles.includes(role)) {
  257. res.status(400).json({ error: 'Invalid globalRole. Must be ADMIN, MEMBER, or PROJECT_USER' });
  258. return;
  259. }
  260. const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
  261. if (!user) {
  262. res.status(404).json({ error: 'User not found' });
  263. return;
  264. }
  265. // Prevent last admin from being demoted
  266. if (role !== 'ADMIN') {
  267. const adminCount = await prisma.user.count({ where: { globalRole: 'ADMIN', active: true } });
  268. if (adminCount <= 1 && user.globalRole === 'ADMIN') {
  269. res.status(400).json({ error: 'Cannot demote the last system admin' });
  270. return;
  271. }
  272. }
  273. const updated = await prisma.user.update({
  274. where: { id: str(req.params.id) },
  275. data: { globalRole: role as any },
  276. select: {
  277. id: true,
  278. email: true,
  279. name: true,
  280. globalRole: true,
  281. avatarUrl: true,
  282. active: true,
  283. createdAt: true,
  284. },
  285. });
  286. res.json({ user: updated });
  287. } catch (err) {
  288. console.error('Update globalRole error:', err);
  289. res.status(500).json({ error: 'Internal server error' });
  290. }
  291. });
  292. // PUT /api/users/:id/active — activate/deactivate user (admin only)
  293. router.put('/:id/active', async (req: Request, res: Response) => {
  294. try {
  295. if (req.user!.globalRole !== 'ADMIN') {
  296. res.status(403).json({ error: 'Forbidden — admin only' });
  297. return;
  298. }
  299. const { active } = req.body;
  300. if (typeof active !== 'boolean') {
  301. res.status(400).json({ error: 'active must be a boolean' });
  302. return;
  303. }
  304. const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
  305. if (!user) {
  306. res.status(404).json({ error: 'User not found' });
  307. return;
  308. }
  309. if (user.id === req.user!.userId) {
  310. res.status(400).json({ error: 'Cannot deactivate your own account' });
  311. return;
  312. }
  313. if (!active && user.globalRole === 'ADMIN') {
  314. const adminCount = await prisma.user.count({ where: { globalRole: 'ADMIN', active: true } });
  315. if (adminCount <= 1) {
  316. res.status(400).json({ error: 'Cannot deactivate the last admin' });
  317. return;
  318. }
  319. }
  320. const updated = await prisma.user.update({
  321. where: { id: str(req.params.id) },
  322. data: { active },
  323. select: {
  324. id: true,
  325. email: true,
  326. name: true,
  327. globalRole: true,
  328. avatarUrl: true,
  329. active: true,
  330. createdAt: true,
  331. },
  332. });
  333. res.json({ user: updated });
  334. } catch (err) {
  335. console.error('Update active error:', err);
  336. res.status(500).json({ error: 'Internal server error' });
  337. }
  338. });
  339. // DELETE /api/users/:id — delete user (admin only)
  340. router.delete('/:id', async (req: Request, res: Response) => {
  341. try {
  342. if (req.user!.globalRole !== 'ADMIN') {
  343. res.status(403).json({ error: 'Forbidden — admin only' });
  344. return;
  345. }
  346. const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
  347. if (!user) {
  348. res.status(404).json({ error: 'User not found' });
  349. return;
  350. }
  351. if (user.id === req.user!.userId) {
  352. res.status(400).json({ error: 'Cannot delete your own account' });
  353. return;
  354. }
  355. await prisma.user.delete({ where: { id: str(req.params.id) } });
  356. res.json({ message: 'User deleted' });
  357. } catch (err) {
  358. console.error('Delete user error:', err);
  359. res.status(500).json({ error: 'Internal server error' });
  360. }
  361. });
  362. export default router;