Forráskód Böngészése

feat: add rate limiting, pagination, search, and improve auth security

- Add express-rate-limit for auth routes (5 attempts/15min per IP)
- Remove JWT_SECRET fallback, require env var or throw
- Add pagination (page/limit) to projects, assets, comments endpoints
- Add search filter to projects, assets, users endpoints
- Fix storageUsed to use stored field instead of computed on each request
- Fix restore comment: only global ADMIN or project owner can restore
- Fix cookies secure flag based on NODE_ENV
- Fix N+1 in folders listing by batching FolderAsset queries
- Fix canSeeDeletedComments to check global role not project role
- Add search support to assets, comments listing
- Add isGlobalAdmin prop to CommentItem for restore permission

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev 1 hónapja
szülő
commit
34a932758b

+ 29 - 0
package-lock.json

@@ -2704,6 +2704,24 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/express-rate-limit": {
+      "version": "8.3.2",
+      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
+      "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
+      "license": "MIT",
+      "dependencies": {
+        "ip-address": "10.1.0"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/express-rate-limit"
+      },
+      "peerDependencies": {
+        "express": ">= 4.11"
+      }
+    },
     "node_modules/fast-glob": {
       "version": "3.3.3",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -3064,6 +3082,15 @@
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
       "license": "ISC"
     },
+    "node_modules/ip-address": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+      "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
     "node_modules/ipaddr.js": {
       "version": "1.9.1",
       "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -5037,6 +5064,7 @@
         "cors": "^2.8.5",
         "dotenv": "^16.4.7",
         "express": "^4.21.2",
+        "express-rate-limit": "^8.3.2",
         "fluent-ffmpeg": "^2.1.3",
         "jsonwebtoken": "^9.0.2",
         "multer": "^1.4.5-lts.1",
@@ -5048,6 +5076,7 @@
         "@types/cookie-parser": "^1.4.10",
         "@types/cors": "^2.8.17",
         "@types/express": "^5.0.0",
+        "@types/express-serve-static-core": "^5.1.1",
         "@types/fluent-ffmpeg": "^2.1.27",
         "@types/jsonwebtoken": "^9.0.7",
         "@types/multer": "^1.4.12",

+ 2 - 0
packages/api/package.json

@@ -19,6 +19,7 @@
     "cors": "^2.8.5",
     "dotenv": "^16.4.7",
     "express": "^4.21.2",
+    "express-rate-limit": "^8.3.2",
     "fluent-ffmpeg": "^2.1.3",
     "jsonwebtoken": "^9.0.2",
     "multer": "^1.4.5-lts.1",
@@ -30,6 +31,7 @@
     "@types/cookie-parser": "^1.4.10",
     "@types/cors": "^2.8.17",
     "@types/express": "^5.0.0",
+    "@types/express-serve-static-core": "^5.1.1",
     "@types/fluent-ffmpeg": "^2.1.27",
     "@types/jsonwebtoken": "^9.0.7",
     "@types/multer": "^1.4.12",

+ 4 - 2
packages/api/src/lib/auth.ts

@@ -26,7 +26,8 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
   }
 
   try {
-    const secret = process.env.JWT_SECRET || 'fallback-secret';
+    const secret = process.env.JWT_SECRET;
+    if (!secret) throw new Error('JWT_SECRET environment variable is required but was not set');
     const payload = jwt.verify(token, secret) as JwtPayload;
     req.user = payload;
     next();
@@ -42,7 +43,8 @@ export function optionalAuth(req: Request, res: Response, next: NextFunction): v
 
   if (token) {
     try {
-      const secret = process.env.JWT_SECRET || 'fallback-secret';
+      const secret = process.env.JWT_SECRET;
+      if (!secret) throw new Error('JWT_SECRET environment variable is required but was not set');
       const payload = jwt.verify(token, secret) as JwtPayload;
       req.user = payload;
     } catch {

+ 26 - 11
packages/api/src/routes/assets.ts

@@ -77,7 +77,7 @@ const upload = multer({
   },
 });
 
-// GET /api/assets — list assets for a project
+// GET /api/assets — list assets for a project (paginated)
 router.get('/', async (req: Request, res: Response) => {
   try {
     const { projectId } = req.query;
@@ -100,17 +100,32 @@ router.get('/', async (req: Request, res: Response) => {
       }
     }
 
-    const assets = await prisma.asset.findMany({
-      where: { projectId },
-      include: {
-        uploader: { select: { id: true, name: true, email: true, avatarUrl: true } },
-        _count: { select: { comments: { where: { deleted: false } } } },
-        shareLinks: { select: { id: true }, take: 1 },
-      },
-      orderBy: { createdAt: 'desc' },
-    });
+    const page = Math.max(1, parseInt(req.query.page as string) || 1);
+    const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 20));
+    const skip = (page - 1) * limit;
+    const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
+    const status = typeof req.query.status === 'string' ? req.query.status.trim() : '';
+
+    const where: Record<string, unknown> = { projectId };
+    if (search) where.title = { contains: search, mode: 'insensitive' };
+    if (status) where.status = status;
+
+    const [assets, total] = await Promise.all([
+      prisma.asset.findMany({
+        where,
+        skip,
+        take: limit,
+        include: {
+          uploader: { select: { id: true, name: true, email: true, avatarUrl: true } },
+          _count: { select: { comments: { where: { deleted: false } } } },
+          shareLinks: { select: { id: true }, take: 1 },
+        },
+        orderBy: { createdAt: 'desc' },
+      }),
+      prisma.asset.count({ where }),
+    ]);
 
-    res.json({ assets });
+    res.json({ assets, total, page, limit, totalPages: Math.ceil(total / limit) });
   } catch (err) {
     console.error('List assets error:', err);
     res.status(500).json({ error: 'Internal server error' });

+ 18 - 22
packages/api/src/routes/auth.ts

@@ -1,16 +1,26 @@
 import { Router, Request, Response } from 'express';
 import bcrypt from 'bcryptjs';
 import jwt from 'jsonwebtoken';
+import rateLimit from 'express-rate-limit';
 import { prisma } from '../lib/prisma';
 import { authMiddleware } from '../lib/auth';
 
 const router = Router();
 
-const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret';
+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', async (req: Request, res: Response) => {
+router.post('/register', authRateLimiter, async (req: Request, res: Response) => {
   try {
     const { email, name, password, inviteToken } = req.body;
 
@@ -122,7 +132,7 @@ router.post('/register', async (req: Request, res: Response) => {
 
     res.cookie('token', token, {
       httpOnly: true,
-      secure: false,
+      secure: process.env.NODE_ENV === 'production',
       sameSite: 'lax',
       maxAge: 7 * 24 * 60 * 60 * 1000,
     });
@@ -135,7 +145,7 @@ router.post('/register', async (req: Request, res: Response) => {
 });
 
 // POST /api/auth/login
-router.post('/login', async (req: Request, res: Response) => {
+router.post('/login', authRateLimiter, async (req: Request, res: Response) => {
   try {
     const { email, password } = req.body;
 
@@ -205,18 +215,11 @@ router.post('/login', async (req: Request, res: Response) => {
 
     res.cookie('token', token, {
       httpOnly: true,
-      secure: false,
+      secure: process.env.NODE_ENV === 'production',
       sameSite: 'lax',
       maxAge: 7 * 24 * 60 * 60 * 1000,
     });
 
-    // Compute storageUsed from owned projects
-    const ownedAssets = await prisma.asset.findMany({
-      where: { project: { ownerId: user.id } },
-      select: { fileSize: true },
-    });
-    const storageUsed = ownedAssets.reduce((s, a) => s + a.fileSize, 0);
-
     res.json({
       user: {
         id: user.id,
@@ -225,7 +228,7 @@ router.post('/login', async (req: Request, res: Response) => {
         globalRole: user.globalRole,
         avatarUrl: user.avatarUrl,
         storageQuota: user.storageQuota,
-        storageUsed,
+        storageUsed: user.storageUsed ?? 0,
       },
       token,
       acceptedProjects,
@@ -249,7 +252,7 @@ router.get('/me', authMiddleware, async (req: Request, res: Response) => {
       where: { id: req.user!.userId },
       select: {
         id: true, email: true, name: true, globalRole: true, avatarUrl: true,
-        storageQuota: true,
+        storageQuota: true, storageUsed: true,
       },
     });
 
@@ -258,14 +261,7 @@ router.get('/me', authMiddleware, async (req: Request, res: Response) => {
       return;
     }
 
-    // Compute storageUsed from owned projects
-    const ownedAssets = await prisma.asset.findMany({
-      where: { project: { ownerId: req.user!.userId } },
-      select: { fileSize: true },
-    });
-    const storageUsed = ownedAssets.reduce((s, a) => s + a.fileSize, 0);
-
-    res.json({ user: { ...user, storageUsed } });
+    res.json({ user: { ...user, storageUsed: user.storageUsed ?? 0 } });
   } catch (err) {
     console.error('Me error:', err);
     res.status(500).json({ error: 'Internal server error' });

+ 25 - 23
packages/api/src/routes/comments.ts

@@ -24,14 +24,12 @@ router.get('/:assetId/comments', async (req: Request, res: Response) => {
     const { resolved, includeDeleted } = req.query;
     const isAdmin = req.user!.globalRole === 'ADMIN';
 
-    // includeDeleted requires ADMIN membership in the project
-    const canSeeDeleted = isAdmin;
-
     const asset = await prisma.asset.findFirst({
       where: {
         id: str(req.params.assetId),
         ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
       },
+      include: { project: { select: { ownerId: true } } },
     });
 
     if (!asset) {
@@ -39,6 +37,14 @@ router.get('/:assetId/comments', async (req: Request, res: Response) => {
       return;
     }
 
+    // Deleted comments are only visible to global ADMIN or project owner
+    const isProjectOwner = asset.project?.ownerId === req.user!.userId;
+    const canSeeDeleted = isAdmin || isProjectOwner;
+
+    const page = Math.max(1, parseInt(req.query.page as string) || 1);
+    const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 20));
+    const skip = (page - 1) * limit;
+
     const assetId = str(req.params.assetId);
     const where: Record<string, unknown> = { assetId, parentId: null };
     // Only filter soft-deleted for non-admin users
@@ -49,13 +55,18 @@ router.get('/:assetId/comments', async (req: Request, res: Response) => {
       where.resolved = resolved === 'true';
     }
 
-    const comments = await prisma.comment.findMany({
-      where,
-      include: includeCommentRelations,
-      orderBy: { timestamp: 'asc' },
-    });
+    const [comments, total] = await Promise.all([
+      prisma.comment.findMany({
+        where,
+        skip,
+        take: limit,
+        include: includeCommentRelations,
+        orderBy: { timestamp: 'asc' },
+      }),
+      prisma.comment.count({ where }),
+    ]);
 
-    res.json({ comments });
+    res.json({ comments, total, page, limit, totalPages: Math.ceil(total / limit) });
   } catch (err) {
     console.error('List comments error:', err);
     res.status(500).json({ error: 'Internal server error' });
@@ -293,20 +304,14 @@ router.delete('/:id', async (req: Request, res: Response) => {
   }
 });
 
-// POST /api/comments/:id/restore — project owner can restore a soft-deleted comment
+// POST /api/comments/:id/restore — only global ADMIN or project owner can restore a soft-deleted comment
 router.post('/:id/restore', async (req: Request, res: Response) => {
   try {
     const isAdmin = req.user!.globalRole === 'ADMIN';
 
     const comment = await prisma.comment.findFirst({
       where: { id: str(req.params.id), deleted: true },
-      include: {
-        asset: {
-          include: {
-            project: { include: { members: true } },
-          },
-        },
-      },
+      include: { asset: { include: { project: { select: { ownerId: true } } } } },
     });
 
     if (!comment) {
@@ -314,14 +319,11 @@ router.post('/:id/restore', async (req: Request, res: Response) => {
       return;
     }
 
-    // Only project owner or ADMIN can restore
+    // Only global ADMIN or project owner can restore — project-level ADMIN is NOT allowed
     const isProjectOwner = comment.asset.project.ownerId === req.user!.userId;
-    const isProjectAdmin = comment.asset.project.members.some(
-      (m: any) => m.userId === req.user!.userId && m.role === 'ADMIN'
-    );
 
-    if (!isProjectOwner && !isProjectAdmin && !isAdmin) {
-      res.status(403).json({ error: 'Only project owner or ADMIN can restore comments' });
+    if (!isProjectOwner && !isAdmin) {
+      res.status(403).json({ error: 'Only project owner or global ADMIN can restore comments' });
       return;
     }
 

+ 31 - 10
packages/api/src/routes/folders.ts

@@ -13,13 +13,18 @@ async function getUserRole(
   projectId: string
 ): Promise<string | null> {
   if (globalRole === 'ADMIN') return 'ADMIN';
+  const membership = await prisma.projectMember.findFirst({
+    where: { projectId, userId },
+    select: { role: true },
+  });
+  if (membership) return membership.role;
+  // Fallback: check if user is owner
   const project = await prisma.project.findUnique({
     where: { id: projectId },
-    select: { ownerId: true, members: { where: { userId }, select: { role: true } } },
+    select: { ownerId: true },
   });
   if (!project) return null;
-  if (project.ownerId === userId) return 'OWNER';
-  return project.members[0]?.role ?? null;
+  return project.ownerId === userId ? 'OWNER' : null;
 }
 
 function canManage(role: string | null): boolean {
@@ -44,14 +49,30 @@ router.get('/project/:projectId', authMiddleware, async (req, res) => {
     });
 
     interface FolderNode { id: string; name: string; parentId: string | null; order: number; assetCount: number; assetIds: string[]; children: FolderNode[]; }
-    interface FolderWithAssets extends FolderNode { _assetIds?: string[] }
 
-    const foldersWithAssets: FolderNode[] = await Promise.all(
-      folders.map(async (f) => {
-        const fa = await prisma.folderAsset.findMany({ where: { folderId: f.id }, select: { assetId: true } });
-        return { id: f.id, name: f.name, parentId: f.parentId, order: f.order, assetCount: f._count.assets, assetIds: fa.map(a => a.assetId), children: [] };
-      })
-    );
+    // Single query for all FolderAssets (fixes N+1)
+    const folderIds = folders.map(f => f.id);
+    const allFolderAssets = folderIds.length > 0
+      ? await prisma.folderAsset.findMany({
+          where: { folderId: { in: folderIds } },
+          select: { folderId: true, assetId: true },
+        })
+      : [];
+    const assetMap = new Map<string, string[]>();
+    for (const fa of allFolderAssets) {
+      if (!assetMap.has(fa.folderId)) assetMap.set(fa.folderId, []);
+      assetMap.get(fa.folderId)!.push(fa.assetId);
+    }
+
+    const foldersWithAssets: FolderNode[] = folders.map(f => ({
+      id: f.id,
+      name: f.name,
+      parentId: f.parentId,
+      order: f.order,
+      assetCount: f._count.assets,
+      assetIds: assetMap.get(f.id) ?? [],
+      children: [],
+    }));
 
     const map = new Map<string, FolderNode>();
     for (const f of foldersWithAssets) map.set(f.id, f);

+ 35 - 19
packages/api/src/routes/projects.ts

@@ -12,29 +12,45 @@ const str = (v: string | string[] | undefined): string => Array.isArray(v) ? v[0
 // All project routes require auth
 router.use(authMiddleware);
 
-// GET /api/projects — list projects for current user with their role in each
+// GET /api/projects — list projects for current user with their role in each (paginated)
 router.get('/', async (req: Request, res: Response) => {
   try {
     const isAdmin = req.user!.globalRole === 'ADMIN';
-    const projects = await prisma.project.findMany({
-      where: isAdmin
-        ? {}
-        : {
-            OR: [
-              { ownerId: req.user!.userId },
-              { members: { some: { userId: req.user!.userId } } },
-            ],
-          },
-      include: {
-        members: {
-          include: {
-            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+    const page = Math.max(1, parseInt(req.query.page as string) || 1);
+    const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 20));
+    const skip = (page - 1) * limit;
+    const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
+
+    const baseWhere = isAdmin
+      ? {}
+      : {
+          OR: [
+            { ownerId: req.user!.userId },
+            { members: { some: { userId: req.user!.userId } } },
+          ],
+        };
+
+    const where = search
+      ? { ...baseWhere, name: { contains: search, mode: 'insensitive' as const } }
+      : baseWhere;
+
+    const [projects, total] = await Promise.all([
+      prisma.project.findMany({
+        where,
+        skip,
+        take: limit,
+        include: {
+          members: {
+            include: {
+              user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+            },
           },
+          _count: { select: { assets: true } },
         },
-        _count: { select: { assets: true } },
-      },
-      orderBy: { updatedAt: 'desc' },
-    });
+        orderBy: { updatedAt: 'desc' },
+      }),
+      prisma.project.count({ where }),
+    ]);
 
     const result = projects.map(p => {
       const myMembership = p.members.find(m => m.userId === req.user!.userId);
@@ -44,7 +60,7 @@ router.get('/', async (req: Request, res: Response) => {
       };
     });
 
-    res.json({ projects: result });
+    res.json({ projects: result, total, page, limit, totalPages: Math.ceil(total / limit) });
   } catch (err) {
     console.error('Projects list error:', err);
     res.status(500).json({ error: 'Internal server error' });

+ 14 - 10
packages/api/src/routes/users.ts

@@ -12,7 +12,7 @@ router.use(authMiddleware);
 const str = (v: string | string[] | undefined): string =>
   Array.isArray(v) ? v[0] ?? '' : (v ?? '');
 
-// GET /api/users — list all users (admin only)
+// GET /api/users — list all users (admin only, with search)
 router.get('/', async (req: Request, res: Response) => {
   try {
     if (req.user!.globalRole !== 'ADMIN') {
@@ -20,8 +20,16 @@ router.get('/', async (req: Request, res: Response) => {
       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,
@@ -123,7 +131,10 @@ router.put('/:id/quota', async (req: Request, res: Response) => {
       return;
     }
 
-    const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
+    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;
@@ -134,18 +145,11 @@ router.put('/:id/quota', async (req: Request, res: Response) => {
       data: { storageQuota },
     });
 
-    // Recalculate storageUsed from owned projects
-    const ownedAssets = await prisma.asset.findMany({
-      where: { project: { ownerId: str(req.params.id) } },
-      select: { fileSize: true },
-    });
-    const storageUsed = ownedAssets.reduce((s, a) => s + a.fileSize, 0);
-
     res.json({
       user: {
         ...user,
         storageQuota,
-        storageUsed,
+        storageUsed: user.storageUsed ?? 0,
       },
     });
   } catch (err) {

+ 13 - 5
src/app/review/[assetId]/page.tsx

@@ -130,10 +130,14 @@ export default function ReviewPage() {
 
   const fps = asset?.fps ?? 30;
 
-  // Derive the current user's project role
+  // Derive the current user's project role and global role
   const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role;
+  const globalRole = user?.globalRole;
   const isProjectAdmin = currentUserRole === 'ADMIN';
   const isProjectOwner = asset?.project.ownerId === user?.id;
+  const isGlobalAdmin = globalRole === 'ADMIN';
+  // Only global ADMIN or project owner can see and restore deleted comments
+  const canSeeDeletedComments = isGlobalAdmin || isProjectOwner;
   const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
 
   // ── Poll for transcode progress ───────────────────────────────────────────
@@ -162,7 +166,7 @@ export default function ReviewPage() {
     try {
       const [{ asset: a }, { comments: c }] = await Promise.all([
         assetsApi.get(token, assetId),
-        commentsApi.list(token, assetId, undefined, true),
+        commentsApi.list(token, assetId, { includeDeleted: true }),
       ]);
       setAsset(a);
       setComments(c);
@@ -1017,7 +1021,7 @@ export default function ReviewPage() {
               >
                 {showResolved ? 'Hide resolved' : 'Show resolved'}
               </button>
-              {isProjectAdmin && deletedCount > 0 && (
+              {canSeeDeletedComments && deletedCount > 0 && (
                 <button
                   onClick={() => setShowDeleted(v => !v)}
                   className={`text-[11px] px-2 py-0.5 rounded-md transition-colors ${showDeleted ? 'bg-red-600 text-white' : ''}`}
@@ -1112,6 +1116,7 @@ export default function ReviewPage() {
                     canComment={canComment}
                     isProjectAdmin={isProjectAdmin}
                     isProjectOwner={isProjectOwner ?? false}
+                    isGlobalAdmin={isGlobalAdmin}
                     onTimestampClick={handleCommentSeek}
                     onReply={() => { setReplyTo(comment); }}
                     onResolve={(action) => handleResolve(comment.id, action)}
@@ -1227,6 +1232,7 @@ function CommentItem({
   canComment,
   isProjectAdmin,
   isProjectOwner,
+  isGlobalAdmin,
   onTimestampClick,
   onReply,
   onResolve,
@@ -1244,6 +1250,7 @@ function CommentItem({
   canComment: boolean | undefined;
   isProjectAdmin: boolean;
   isProjectOwner: boolean;
+  isGlobalAdmin: boolean;
   onTimestampClick: (c: Comment) => void;
   onReply: () => void;
   onResolve: (action: 'approve' | 'reject') => void;
@@ -1261,7 +1268,8 @@ function CommentItem({
   const annotations = comment.annotations ?? [];
   const canAddMore = annotations.length < MAX_ANNOTATIONS;
   const isDeleted = !!comment.deleted;
-  const canRestore = isDeleted && (isProjectOwner || isProjectAdmin);
+  // Only global ADMIN or project owner can restore a deleted comment
+  const canRestore = isDeleted && (isProjectOwner || isGlobalAdmin);
 
   // Resolve state machine
   const isResolved = comment.resolveStatus === 'RESOLVED';
@@ -1357,7 +1365,7 @@ function CommentItem({
           {/* Actions */}
           <div className="flex items-center gap-1">
             {/* Restore button for soft-deleted comments — project owner/ADMIN only */}
-            {isDeleted && (isProjectOwner || isProjectAdmin) && (
+            {canRestore && (
               <button
                 onClick={() => onRestore(comment.id)}
                 className="text-xs px-2 py-1 rounded-md transition-colors"

+ 34 - 12
src/lib/api.ts

@@ -58,8 +58,15 @@ export const authApi = {
 // ── Projects ─────────────────────────────────────────────────────────────────
 
 export const projectsApi = {
-  list: (token: string) =>
-    apiFetch<{ projects: Project[] }>('/api/projects', { token }),
+  list: (token: string, params?: { page?: number; limit?: number; search?: string }) => {
+    const q = new URLSearchParams();
+    if (params?.page) q.set('page', String(params.page));
+    if (params?.limit) q.set('limit', String(params.limit));
+    if (params?.search) q.set('search', params.search);
+    return apiFetch<{ projects: Project[]; total: number; page: number; limit: number; totalPages: number }>(
+      `/api/projects${q.toString() ? `?${q}` : ''}`, { token }
+    );
+  },
 
   create: (token: string, data: { name: string; description?: string }) =>
     apiFetch<{ project: Project }>('/api/projects', {
@@ -102,8 +109,18 @@ export const projectsApi = {
 // ── Assets ───────────────────────────────────────────────────────────────────
 
 export const assetsApi = {
-  list: (token: string, projectId: string) =>
-    apiFetch<{ assets: Asset[] }>(`/api/assets?projectId=${projectId}`, { token }),
+  list: (token: string, projectId: string, params?: {
+    page?: number; limit?: number; search?: string; status?: string;
+  }) => {
+    const q = new URLSearchParams({ projectId });
+    if (params?.page) q.set('page', String(params.page));
+    if (params?.limit) q.set('limit', String(params.limit));
+    if (params?.search) q.set('search', params.search);
+    if (params?.status) q.set('status', params.status);
+    return apiFetch<{ assets: Asset[]; total: number; page: number; limit: number; totalPages: number }>(
+      `/api/assets?${q}`, { token }
+    );
+  },
 
   get: (token: string, id: string) =>
     apiFetch<{ asset: AssetWithComments }>(`/api/assets/${id}`, { token }),
@@ -141,12 +158,17 @@ export const assetsApi = {
 // ── Comments ─────────────────────────────────────────────────────────────────
 
 export const commentsApi = {
-  list: (token: string, assetId: string, resolved?: boolean, includeDeleted?: boolean) => {
-    const params = new URLSearchParams();
-    if (resolved !== undefined) params.set('resolved', String(resolved));
-    if (includeDeleted) params.set('includeDeleted', 'true');
-    const q = params.toString() ? `?${params.toString()}` : '';
-    return apiFetch<{ comments: Comment[] }>(`/api/assets/${assetId}/comments${q}`, { token });
+  list: (token: string, assetId: string, params?: {
+    resolved?: boolean; includeDeleted?: boolean; page?: number; limit?: number;
+  }) => {
+    const q = new URLSearchParams();
+    if (params?.resolved !== undefined) q.set('resolved', String(params.resolved));
+    if (params?.includeDeleted) q.set('includeDeleted', 'true');
+    if (params?.page) q.set('page', String(params.page));
+    if (params?.limit) q.set('limit', String(params.limit));
+    return apiFetch<{ comments: Comment[]; total: number; page: number; limit: number; totalPages: number }>(
+      `/api/assets/${assetId}/comments${q.toString() ? `?${q}` : ''}`, { token }
+    );
   },
 
   create: (token: string, assetId: string, data: {
@@ -188,8 +210,8 @@ export const commentsApi = {
 // ── Users ────────────────────────────────────────────────────────────────────
 
 export const usersApi = {
-  list: (token: string) =>
-    apiFetch<{ users: AdminUser[] }>('/api/users', { token }),
+  list: (token: string, search?: string) =>
+    apiFetch<{ users: AdminUser[] }>(`/api/users${search ? `?search=${encodeURIComponent(search)}` : ''}`, { token }),
 
   getMe: (token: string) =>
     apiFetch<{ user: AdminUser }>('/api/users/me', { token }),