| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407 |
- import { Router, Request, Response } from 'express';
- import bcrypt from 'bcryptjs';
- import multer from 'multer';
- import path from 'path';
- import { v4 as uuidv4 } from 'uuid';
- import { prisma } from '../lib/prisma';
- import { authMiddleware } from '../lib/auth';
- const router = Router();
- router.use(authMiddleware);
- const str = (v: string | string[] | undefined): string =>
- Array.isArray(v) ? v[0] ?? '' : (v ?? '');
- // GET /api/users — list all users (admin only, with search)
- router.get('/', async (req: Request, res: Response) => {
- try {
- if (req.user!.globalRole !== 'ADMIN') {
- res.status(403).json({ error: 'Forbidden — admin only' });
- return;
- }
- const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
- // storageUsed = sum of fileSize of all assets in projects this user owns
- const users = await prisma.user.findMany({
- where: search ? {
- OR: [
- { name: { contains: search, mode: 'insensitive' } },
- { email: { contains: search, mode: 'insensitive' } },
- ],
- } : {},
- select: {
- id: true,
- email: true,
- name: true,
- globalRole: true,
- avatarUrl: true,
- active: true,
- storageQuota: true,
- createdAt: true,
- // total storage used = sum of assets in projects this user owns
- projects: {
- select: {
- id: true,
- assets: {
- select: { fileSize: true },
- },
- },
- },
- _count: {
- select: {
- memberships: true,
- comments: true,
- },
- },
- },
- orderBy: { createdAt: 'desc' },
- });
- // Compute storageUsed and ownedProjects from owned projects
- const usersWithStorage = users.map(u => ({
- ...u,
- storageUsed: u.projects.reduce(
- (sum, p) => sum + p.assets.reduce((s, a) => s + a.fileSize, 0),
- 0
- ),
- ownedProjects: u.projects.length,
- }));
- res.json({ users: usersWithStorage });
- } catch (err) {
- console.error('List users error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // GET /api/users/me — get current user profile
- router.get('/me', 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,
- active: true,
- createdAt: true,
- _count: {
- select: {
- memberships: true,
- comments: true,
- },
- },
- },
- });
- if (!user) {
- res.status(404).json({ error: 'User not found' });
- return;
- }
- res.json({ user });
- } catch (err) {
- console.error('Get me error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // PUT /api/users/:id/quota — update storage quota (admin only)
- router.put('/:id/quota', async (req: Request, res: Response) => {
- try {
- if (req.user!.globalRole !== 'ADMIN') {
- res.status(403).json({ error: 'Forbidden — admin only' });
- return;
- }
- const { storageQuota } = req.body;
- if (typeof storageQuota !== 'number' || !Number.isInteger(storageQuota) || storageQuota < 0) {
- res.status(400).json({ error: 'storageQuota must be a non-negative integer (bytes)' });
- return;
- }
- // Allow max 1 TB
- const MAX_QUOTA = 1024 * 1024 * 1024 * 1024;
- if (storageQuota > MAX_QUOTA) {
- res.status(400).json({ error: `storageQuota cannot exceed ${MAX_QUOTA} bytes (1 TB)` });
- return;
- }
- const user = await prisma.user.findUnique({
- where: { id: str(req.params.id) },
- select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true, storageUsed: true },
- });
- if (!user) {
- res.status(404).json({ error: 'User not found' });
- return;
- }
- await prisma.user.update({
- where: { id: str(req.params.id) },
- data: { storageQuota },
- });
- res.json({
- user: {
- ...user,
- storageQuota,
- storageUsed: user.storageUsed ?? 0,
- },
- });
- } catch (err) {
- console.error('Update quota error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // PUT /api/users/me — update current user profile
- router.put('/me', async (req: Request, res: Response) => {
- try {
- const { name, avatarUrl, currentPassword, newPassword } = req.body;
- if (newPassword) {
- if (!currentPassword) {
- res.status(400).json({ error: 'currentPassword is required to change password' });
- return;
- }
- if (newPassword.length < 6) {
- res.status(400).json({ error: 'New password must be at least 6 characters' });
- return;
- }
- const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
- if (!user) {
- res.status(404).json({ error: 'User not found' });
- return;
- }
- const valid = await bcrypt.compare(currentPassword, user.password);
- if (!valid) {
- res.status(401).json({ error: 'Current password is incorrect' });
- return;
- }
- const hashed = await bcrypt.hash(newPassword, 12);
- const updated = await prisma.user.update({
- where: { id: req.user!.userId },
- data: {
- name: name?.trim() || undefined,
- avatarUrl: avatarUrl ?? undefined,
- password: hashed,
- },
- select: {
- id: true,
- email: true,
- name: true,
- globalRole: true,
- avatarUrl: true,
- active: true,
- },
- });
- res.json({ user: updated });
- return;
- }
- const updated = await prisma.user.update({
- where: { id: req.user!.userId },
- data: {
- name: name?.trim() || undefined,
- avatarUrl: avatarUrl ?? undefined,
- },
- select: {
- id: true,
- email: true,
- name: true,
- globalRole: true,
- avatarUrl: true,
- active: true,
- },
- });
- res.json({ user: updated });
- } catch (err) {
- console.error('Update me error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // ── Avatar upload ────────────────────────────────────────────────────────────
- const avatarStorage = multer.diskStorage({
- destination: (_req, _file, cb) => {
- cb(null, path.resolve(__dirname, '../../uploads/avatars'));
- },
- filename: (_req, file, cb) => {
- const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
- cb(null, `${uuidv4()}${ext}`);
- },
- });
- const avatarUpload = multer({
- storage: avatarStorage,
- limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
- fileFilter: (_req, file, cb) => {
- if (['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.mimetype)) {
- cb(null, true);
- } else {
- cb(new Error('Only JPEG, PNG, WebP, or GIF images are allowed'));
- }
- },
- });
- // POST /api/users/me/avatar — upload avatar image
- router.post('/me/avatar', avatarUpload.single('avatar'), async (req: Request, res: Response) => {
- try {
- if (!req.file) {
- res.status(400).json({ error: 'No file uploaded' });
- return;
- }
- const avatarUrl = `/uploads/avatars/${req.file!.filename}`;
- const updated = await prisma.user.update({
- where: { id: req.user!.userId },
- data: { avatarUrl },
- select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true },
- });
- res.json({ user: updated, avatarUrl });
- } catch (err) {
- console.error('Avatar upload error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // PUT /api/users/:id/role — change user globalRole (admin only)
- router.put('/:id/role', async (req: Request, res: Response) => {
- try {
- if (req.user!.globalRole !== 'ADMIN') {
- res.status(403).json({ error: 'Forbidden — admin only' });
- return;
- }
- const { role } = req.body;
- const validRoles = ['ADMIN', 'MEMBER', 'PROJECT_USER'];
- if (!validRoles.includes(role)) {
- res.status(400).json({ error: 'Invalid globalRole. Must be ADMIN, MEMBER, or PROJECT_USER' });
- return;
- }
- const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
- if (!user) {
- res.status(404).json({ error: 'User not found' });
- return;
- }
- // Prevent last admin from being demoted
- if (role !== 'ADMIN') {
- const adminCount = await prisma.user.count({ where: { globalRole: 'ADMIN', active: true } });
- if (adminCount <= 1 && user.globalRole === 'ADMIN') {
- res.status(400).json({ error: 'Cannot demote the last system admin' });
- return;
- }
- }
- const updated = await prisma.user.update({
- where: { id: str(req.params.id) },
- data: { globalRole: role as any },
- select: {
- id: true,
- email: true,
- name: true,
- globalRole: true,
- avatarUrl: true,
- active: true,
- createdAt: true,
- },
- });
- res.json({ user: updated });
- } catch (err) {
- console.error('Update globalRole error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // PUT /api/users/:id/active — activate/deactivate user (admin only)
- router.put('/:id/active', async (req: Request, res: Response) => {
- try {
- if (req.user!.globalRole !== 'ADMIN') {
- res.status(403).json({ error: 'Forbidden — admin only' });
- return;
- }
- const { active } = req.body;
- if (typeof active !== 'boolean') {
- res.status(400).json({ error: 'active must be a boolean' });
- return;
- }
- const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
- if (!user) {
- res.status(404).json({ error: 'User not found' });
- return;
- }
- if (user.id === req.user!.userId) {
- res.status(400).json({ error: 'Cannot deactivate your own account' });
- return;
- }
- if (!active && user.globalRole === 'ADMIN') {
- const adminCount = await prisma.user.count({ where: { globalRole: 'ADMIN', active: true } });
- if (adminCount <= 1) {
- res.status(400).json({ error: 'Cannot deactivate the last admin' });
- return;
- }
- }
- const updated = await prisma.user.update({
- where: { id: str(req.params.id) },
- data: { active },
- select: {
- id: true,
- email: true,
- name: true,
- globalRole: true,
- avatarUrl: true,
- active: true,
- createdAt: true,
- },
- });
- res.json({ user: updated });
- } catch (err) {
- console.error('Update active error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // DELETE /api/users/:id — delete user (admin only)
- router.delete('/:id', async (req: Request, res: Response) => {
- try {
- if (req.user!.globalRole !== 'ADMIN') {
- res.status(403).json({ error: 'Forbidden — admin only' });
- return;
- }
- const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
- if (!user) {
- res.status(404).json({ error: 'User not found' });
- return;
- }
- if (user.id === req.user!.userId) {
- res.status(400).json({ error: 'Cannot delete your own account' });
- return;
- }
- await prisma.user.delete({ where: { id: str(req.params.id) } });
- res.json({ message: 'User deleted' });
- } catch (err) {
- console.error('Delete user error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- export default router;
|