Browse Source

feat: invitation system, per-project RBAC, annotation display, and uploads fix

- Add invitation system: UUID tokens, 7-day expiry, auto-accept on register/login
- Per-project RBAC: ADMIN/EDITOR/REVIEWER/VIEWER roles per project
- Global roles: ADMIN (system) vs MEMBER, separated from per-project roles
- New Prisma schema: Invitation model, GlobalRole enum, Project.ownerId
- New invitation API routes: verify, accept, list, create, revoke, resend
- Auth API: auto-join projects on register/login when pending invitation exists
- Fix annotation display: ±3-frame window using FPS, separate display canvas
- Fix upload: MulterFileTypeError with statusCode for JSON error responses
- Fix FFmpeg HLS: simplified options, removed broken multi-output map strings
- Project detail: videos/members tabs, invite form, member role management, pending invites
- Public invite page: verify token, show project+role, accept & join flow
- Login/register: invite_token param handling, accepted projects notification
- Suspense boundaries for useSearchParams on auth pages
- User management page: role change, activate/deactivate, delete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Son Nguyen 1 month ago
parent
commit
c3d751e44d

+ 42 - 11
packages/api/prisma/schema.prisma

@@ -16,14 +16,14 @@ model User {
   name        String
   password    String
   avatarUrl   String?
-  role        Role      @default(REVIEWER)
+  globalRole  GlobalRole @default(MEMBER)
   active      Boolean   @default(true)
   createdAt   DateTime  @default(now())
   updatedAt   DateTime  @updatedAt
 
   memberships ProjectMember[]
   comments    Comment[]
-  projects    Project[]
+  projects    Project[]    // projects where this user is the owner
 }
 
 model Project {
@@ -34,18 +34,19 @@ model Project {
   createdAt   DateTime @default(now())
   updatedAt   DateTime @updatedAt
 
-  assets Asset[]
-  members ProjectMember[]
-  owner   User     @relation(fields: [ownerId], references: [id])
+  assets      Asset[]
+  members     ProjectMember[]
+  invitations Invitation[]
+  owner       User     @relation(fields: [ownerId], references: [id])
 }
 
 model ProjectMember {
-  id        String @id @default(cuid())
-  userId    String
-  projectId String
-  role      Role  @default(REVIEWER)
-  isOwner   Boolean @default(false)
-  joinedAt  DateTime @default(now())
+  id         String @id @default(cuid())
+  userId     String
+  projectId  String
+  role       Role   @default(REVIEWER)
+  joinedAt   DateTime @default(now())
+  invitedBy  String?    // userId who sent the invite
 
   user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)
   project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@ -99,6 +100,36 @@ enum Role {
   VIEWER
 }
 
+enum GlobalRole {
+  ADMIN       // system-wide admin: manage users, all projects
+  MEMBER      // regular user: create projects, join via invite
+}
+
+enum InvitationStatus {
+  PENDING
+  ACCEPTED
+  EXPIRED
+  REVOKED
+}
+
+model Invitation {
+  id         String           @id @default(cuid())
+  email     String            // invitee email
+  projectId String
+  role      Role              @default(REVIEWER)
+  token     String            @unique
+  status    InvitationStatus  @default(PENDING)
+  invitedBy String?           // userId who sent the invite
+  expiresAt DateTime
+  createdAt DateTime         @default(now())
+
+  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+
+  @@index([projectId])
+  @@index([email])
+  @@index([token])
+}
+
 enum AssetStatus {
   PENDING_REVIEW
   CHANGES_REQUESTED

+ 5 - 1
packages/api/src/index.ts

@@ -9,6 +9,7 @@ import projectRoutes from './routes/projects';
 import assetRoutes from './routes/assets';
 import commentRoutes from './routes/comments';
 import userRoutes from './routes/users';
+import invitationRoutes from './routes/invitations';
 
 const app = express();
 const PORT = process.env.API_PORT || 3001;
@@ -41,6 +42,7 @@ app.use('/api/assets', assetRoutes);
 app.use('/api/assets', commentRoutes);
 app.use('/api/comments', commentRoutes);
 app.use('/api/users', userRoutes);
+app.use('/api/invitations', invitationRoutes);
 
 // ── 404 handler ─────────────────────────────────────────────────────────────
 app.use((_req, res) => {
@@ -50,7 +52,9 @@ app.use((_req, res) => {
 // ── Error handler ─────────────────────────────────────────────────────────────
 app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
   console.error('Unhandled error:', err);
-  res.status(500).json({ error: 'Internal server error' });
+  const status = (err as any).statusCode ?? 500;
+  const message = (err as any).statusCode ? err.message : 'Internal server error';
+  res.status(status).json({ error: message });
 });
 
 // ── Start ────────────────────────────────────────────────────────────────────

+ 1 - 1
packages/api/src/lib/auth.ts

@@ -4,7 +4,7 @@ import jwt from 'jsonwebtoken';
 export interface JwtPayload {
   userId: string;
   email: string;
-  role: string;
+  globalRole: string;
 }
 
 declare global {

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

@@ -28,6 +28,16 @@ const storage = multer.diskStorage({
   },
 });
 
+// Custom error class so multer passes it to Express error handler as JSON
+class MulterFileTypeError extends Error {
+  code = 'ONLY_VIDEO';
+  statusCode = 400;
+  constructor() {
+    super('Only video files are allowed');
+    this.name = 'MulterFileTypeError';
+  }
+}
+
 const upload = multer({
   storage,
   limits: { fileSize: MAX_SIZE },
@@ -36,7 +46,7 @@ const upload = multer({
     if (allowed.includes(file.mimetype)) {
       cb(null, true);
     } else {
-      cb(new Error('Only video files are allowed'));
+      cb(new MulterFileTypeError());
     }
   },
 });

+ 72 - 9
packages/api/src/routes/auth.ts

@@ -33,23 +33,54 @@ router.post('/register', async (req: Request, res: Response) => {
     const hashed = await bcrypt.hash(password, 12);
     const user = await prisma.user.create({
       data: { email, name, password: hashed },
-      select: { id: true, email: true, name: true, role: true, avatarUrl: true },
+      select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true },
     });
 
     const token = jwt.sign(
-      { userId: user.id, email: user.email, role: user.role },
+      { userId: user.id, email: user.email, globalRole: user.globalRole },
       JWT_SECRET,
       { expiresIn: JWT_EXPIRES_IN } as jwt.SignOptions
     );
 
+    // Accept any pending invitations for this email
+    const pendingInvites = await prisma.invitation.findMany({
+      where: { email, status: 'PENDING', expiresAt: { gt: new Date() } },
+    });
+
+    const acceptedProjects: { projectId: string; projectName: string }[] = [];
+    for (const invite of pendingInvites) {
+      const existingMember = await prisma.projectMember.findFirst({
+        where: { projectId: invite.projectId, userId: user.id },
+      });
+      if (!existingMember) {
+        await prisma.projectMember.create({
+          data: {
+            userId: user.id,
+            projectId: invite.projectId,
+            role: invite.role,
+            invitedBy: invite.invitedBy,
+          },
+        });
+        const project = await prisma.project.findUnique({
+          where: { id: invite.projectId },
+          select: { id: true, name: true },
+        });
+        if (project) acceptedProjects.push({ projectId: project.id, projectName: project.name });
+      }
+      await prisma.invitation.update({
+        where: { id: invite.id },
+        data: { status: 'ACCEPTED' },
+      });
+    }
+
     res.cookie('token', token, {
       httpOnly: true,
-      secure: false, // set true if serving over HTTPS
+      secure: false,
       sameSite: 'lax',
-      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
+      maxAge: 7 * 24 * 60 * 60 * 1000,
     });
 
-    res.status(201).json({ user, token });
+    res.status(201).json({ user, token, acceptedProjects });
   } catch (err) {
     console.error('Register error:', err);
     res.status(500).json({ error: 'Internal server error' });
@@ -79,14 +110,45 @@ router.post('/login', async (req: Request, res: Response) => {
     }
 
     const token = jwt.sign(
-      { userId: user.id, email: user.email, role: user.role },
+      { userId: user.id, email: user.email, globalRole: user.globalRole },
       JWT_SECRET,
       { expiresIn: JWT_EXPIRES_IN } as jwt.SignOptions
     );
 
+    // Auto-accept pending invitations for this email
+    const pendingInvites = await prisma.invitation.findMany({
+      where: { email: user.email, status: 'PENDING', expiresAt: { gt: new Date() } },
+    });
+
+    const acceptedProjects: { projectId: string; projectName: string }[] = [];
+    for (const invite of pendingInvites) {
+      const existingMember = await prisma.projectMember.findFirst({
+        where: { projectId: invite.projectId, userId: user.id },
+      });
+      if (!existingMember) {
+        await prisma.projectMember.create({
+          data: {
+            userId: user.id,
+            projectId: invite.projectId,
+            role: invite.role,
+            invitedBy: invite.invitedBy,
+          },
+        });
+        const project = await prisma.project.findUnique({
+          where: { id: invite.projectId },
+          select: { id: true, name: true },
+        });
+        if (project) acceptedProjects.push({ projectId: project.id, projectName: project.name });
+      }
+      await prisma.invitation.update({
+        where: { id: invite.id },
+        data: { status: 'ACCEPTED' },
+      });
+    }
+
     res.cookie('token', token, {
       httpOnly: true,
-      secure: false, // set true if serving over HTTPS
+      secure: false,
       sameSite: 'lax',
       maxAge: 7 * 24 * 60 * 60 * 1000,
     });
@@ -96,10 +158,11 @@ router.post('/login', async (req: Request, res: Response) => {
         id: user.id,
         email: user.email,
         name: user.name,
-        role: user.role,
+        globalRole: user.globalRole,
         avatarUrl: user.avatarUrl,
       },
       token,
+      acceptedProjects,
     });
   } catch (err) {
     console.error('Login error:', err);
@@ -118,7 +181,7 @@ router.get('/me', authMiddleware, async (req: Request, res: Response) => {
   try {
     const user = await prisma.user.findUnique({
       where: { id: req.user!.userId },
-      select: { id: true, email: true, name: true, role: true, avatarUrl: true },
+      select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true },
     });
 
     if (!user) {

+ 320 - 0
packages/api/src/routes/invitations.ts

@@ -0,0 +1,320 @@
+import { Router, Request, Response } from 'express';
+import { randomBytes } from 'crypto';
+import { prisma } from '../lib/prisma';
+import { authMiddleware, optionalAuth } from '../lib/auth';
+
+const router = Router();
+const INVITE_EXPIRY_DAYS = 7;
+const INVITE_EXPIRY_MS = INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
+
+function str(v: string | string[] | undefined): string {
+  return Array.isArray(v) ? v[0] ?? '' : (v ?? '');
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+/** Check if current user can invite to this project */
+async function canInvite(projectId: string, userId: string): Promise<boolean> {
+  const member = await prisma.projectMember.findFirst({
+    where: { projectId, userId },
+  });
+  return !!member && (member.role === 'ADMIN' || member.role === 'EDITOR');
+}
+
+/** Auto-expire stale invitations */
+async function expireOldInvitations() {
+  await prisma.invitation.updateMany({
+    where: {
+      status: 'PENDING',
+      expiresAt: { lt: new Date() },
+    },
+    data: { status: 'EXPIRED' },
+  });
+}
+
+// ── GET /api/invitations/:token ─ public verify (no auth needed) ───────────────
+router.get('/:token', optionalAuth, async (req: Request, res: Response) => {
+  try {
+    await expireOldInvitations();
+
+    const invitation = await prisma.invitation.findUnique({
+      where: { token: str(req.params.token) },
+      include: {
+        project: { select: { id: true, name: true } },
+      },
+    });
+
+    if (!invitation) {
+      res.status(404).json({ error: 'Invitation not found' });
+      return;
+    }
+
+    if (invitation.status !== 'PENDING') {
+      res.status(410).json({ error: `Invitation has been ${invitation.status.toLowerCase()}` });
+      return;
+    }
+
+    if (invitation.expiresAt < new Date()) {
+      res.status(410).json({ error: 'Invitation has expired' });
+      return;
+    }
+
+    // If user is logged in, check if this is their invitation
+    const isOwnInvitation = req.user?.email === invitation.email;
+    const alreadyMember = req.user
+      ? !!(await prisma.projectMember.findFirst({
+          where: { projectId: invitation.projectId, userId: req.user.userId },
+        }))
+      : false;
+
+    res.json({
+      invitation: {
+        id: invitation.id,
+        email: invitation.email,
+        role: invitation.role,
+        projectName: invitation.project.name,
+        projectId: invitation.projectId,
+        expiresAt: invitation.expiresAt,
+        isOwnInvitation,
+        alreadyMember,
+        isLoggedIn: !!req.user,
+      },
+    });
+  } catch (err) {
+    console.error('Verify invitation error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── POST /api/invitations/:token/accept ─ public (no auth needed, but user must be logged in) ─
+router.post('/:token/accept', authMiddleware, async (req: Request, res: Response) => {
+  try {
+    await expireOldInvitations();
+
+    const invitation = await prisma.invitation.findUnique({
+      where: { token: str(req.params.token) },
+    });
+
+    if (!invitation) {
+      res.status(404).json({ error: 'Invitation not found' });
+      return;
+    }
+
+    if (invitation.status !== 'PENDING') {
+      res.status(410).json({ error: `Invitation has been ${invitation.status.toLowerCase()}` });
+      return;
+    }
+
+    if (invitation.expiresAt < new Date()) {
+      res.status(410).json({ error: 'Invitation has expired' });
+      return;
+    }
+
+    if (invitation.email !== req.user!.email) {
+      res.status(403).json({ error: 'This invitation was sent to a different email address' });
+      return;
+    }
+
+    // Check if already a member
+    const existing = await prisma.projectMember.findFirst({
+      where: { projectId: invitation.projectId, userId: req.user!.userId },
+    });
+
+    if (existing) {
+      // Mark invitation as accepted anyway
+      await prisma.invitation.update({
+        where: { id: invitation.id },
+        data: { status: 'ACCEPTED' },
+      });
+      res.json({ message: 'Already a member', projectId: invitation.projectId });
+      return;
+    }
+
+    // Create membership + mark invitation accepted (transaction)
+    const [member] = await prisma.$transaction([
+      prisma.projectMember.create({
+        data: {
+          userId: req.user!.userId,
+          projectId: invitation.projectId,
+          role: invitation.role,
+          invitedBy: invitation.invitedBy,
+        },
+        include: {
+          project: { select: { id: true, name: true } },
+        },
+      }),
+      prisma.invitation.update({
+        where: { id: invitation.id },
+        data: { status: 'ACCEPTED' },
+      }),
+    ]);
+
+    res.json({ member });
+  } catch (err) {
+    console.error('Accept invitation error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── Project-scoped invitation routes (require auth + project membership) ───────
+
+// GET /api/projects/:projectId/invitations — list pending invitations
+router.get('/project/:projectId', authMiddleware, async (req: Request, res: Response) => {
+  try {
+    const projectId = str(req.params.projectId);
+    if (!(await canInvite(projectId, req.user!.userId))) {
+      res.status(403).json({ error: 'Forbidden' });
+      return;
+    }
+
+    await expireOldInvitations();
+
+    const invitations = await prisma.invitation.findMany({
+      where: { projectId },
+      orderBy: { createdAt: 'desc' },
+    });
+
+    res.json({ invitations });
+  } catch (err) {
+    console.error('List invitations error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// POST /api/projects/:projectId/invitations — create invitation
+router.post('/project/:projectId', authMiddleware, async (req: Request, res: Response) => {
+  try {
+    const projectId = str(req.params.projectId);
+    if (!(await canInvite(projectId, req.user!.userId))) {
+      res.status(403).json({ error: 'Forbidden — must be admin or editor' });
+      return;
+    }
+
+    const { email, role = 'REVIEWER' } = req.body as { email: string; role?: string };
+
+    if (!email) {
+      res.status(400).json({ error: 'Email is required' });
+      return;
+    }
+
+    const validRoles = ['ADMIN', 'EDITOR', 'REVIEWER', 'VIEWER'];
+    if (!validRoles.includes(role)) {
+      res.status(400).json({ error: 'Invalid role' });
+      return;
+    }
+
+    // Check if already a member
+    const existingMember = await prisma.user.findUnique({ where: { email } });
+    if (existingMember) {
+      const member = await prisma.projectMember.findFirst({
+        where: { projectId, userId: existingMember.id },
+      });
+      if (member) {
+        res.status(409).json({ error: 'User is already a member of this project' });
+        return;
+      }
+    }
+
+    // Check if there's already a pending invitation
+    const existingInvite = await prisma.invitation.findFirst({
+      where: { projectId, email, status: 'PENDING' },
+    });
+    if (existingInvite) {
+      res.status(409).json({ error: 'A pending invitation already exists for this email' });
+      return;
+    }
+
+    const token = randomBytes(32).toString('hex');
+    const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS);
+
+    const invitation = await prisma.invitation.create({
+      data: {
+        email,
+        projectId,
+        role: role as any,
+        token,
+        invitedBy: req.user!.userId,
+        expiresAt,
+      },
+    });
+
+    // Return full invite URL
+    const inviteUrl = `/invite/${token}`;
+    res.status(201).json({ invitation, inviteUrl });
+  } catch (err) {
+    console.error('Create invitation error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// DELETE /api/invitations/:id — revoke invitation (project admin/editor only)
+router.delete('/:id', authMiddleware, async (req: Request, res: Response) => {
+  try {
+    const invitation = await prisma.invitation.findUnique({
+      where: { id: str(req.params.id) },
+    });
+
+    if (!invitation) {
+      res.status(404).json({ error: 'Invitation not found' });
+      return;
+    }
+
+    if (!(await canInvite(invitation.projectId, req.user!.userId))) {
+      res.status(403).json({ error: 'Forbidden' });
+      return;
+    }
+
+    if (invitation.status !== 'PENDING') {
+      res.status(400).json({ error: 'Can only revoke pending invitations' });
+      return;
+    }
+
+    await prisma.invitation.update({
+      where: { id: invitation.id },
+      data: { status: 'REVOKED' },
+    });
+
+    res.json({ message: 'Invitation revoked' });
+  } catch (err) {
+    console.error('Revoke invitation error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// Resend invitation — create new token for same email
+router.post('/project/:projectId/resend', authMiddleware, async (req: Request, res: Response) => {
+  try {
+    const projectId = str(req.params.projectId);
+    if (!(await canInvite(projectId, req.user!.userId))) {
+      res.status(403).json({ error: 'Forbidden' });
+      return;
+    }
+
+    const { invitationId } = req.body as { invitationId: string };
+    if (!invitationId) {
+      res.status(400).json({ error: 'invitationId required' });
+      return;
+    }
+
+    const existing = await prisma.invitation.findUnique({ where: { id: invitationId } });
+    if (!existing || existing.projectId !== projectId) {
+      res.status(404).json({ error: 'Invitation not found' });
+      return;
+    }
+
+    const token = randomBytes(32).toString('hex');
+    const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS);
+
+    const invitation = await prisma.invitation.update({
+      where: { id: invitationId },
+      data: { token, expiresAt, status: 'PENDING' },
+    });
+
+    res.json({ invitation, inviteUrl: `/invite/${token}` });
+  } catch (err) {
+    console.error('Resend invitation error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+export default router;

+ 51 - 4
packages/api/src/routes/projects.ts

@@ -51,7 +51,7 @@ router.post('/', async (req: Request, res: Response) => {
         description: description || null,
         ownerId: req.user!.userId,
         members: {
-          create: { userId: req.user!.userId, role: 'ADMIN', isOwner: true },
+          create: { userId: req.user!.userId, role: 'ADMIN' },
         },
       },
       include: {
@@ -207,11 +207,18 @@ router.post('/:id/members', async (req: Request, res: Response) => {
 router.delete('/:id/members/:userId', async (req: Request, res: Response) => {
   try {
     const membership = await prisma.projectMember.findFirst({
-      where: { projectId: str(req.params.id), userId: req.user!.userId, role: 'ADMIN' },
+      where: { projectId: str(req.params.id), userId: req.user!.userId },
     });
 
-    if (!membership) {
-      res.status(403).json({ error: 'Forbidden' });
+    if (!membership || membership.role !== 'ADMIN') {
+      res.status(403).json({ error: 'Forbidden — must be admin' });
+      return;
+    }
+
+    // Cannot remove project owner
+    const project = await prisma.project.findUnique({ where: { id: str(req.params.id) } });
+    if (project?.ownerId === str(req.params.userId)) {
+      res.status(400).json({ error: 'Cannot remove the project owner' });
       return;
     }
 
@@ -226,4 +233,44 @@ router.delete('/:id/members/:userId', async (req: Request, res: Response) => {
   }
 });
 
+// PUT /api/projects/:id/members/:userId — change member role
+router.put('/:id/members/:userId', async (req: Request, res: Response) => {
+  try {
+    const membership = await prisma.projectMember.findFirst({
+      where: { projectId: str(req.params.id), userId: req.user!.userId },
+    });
+    if (!membership || membership.role !== 'ADMIN') {
+      res.status(403).json({ error: 'Forbidden — must be admin' });
+      return;
+    }
+
+    const { role } = req.body;
+    const validRoles = ['ADMIN', 'EDITOR', 'REVIEWER', 'VIEWER'];
+    if (!validRoles.includes(role)) {
+      res.status(400).json({ error: 'Invalid role' });
+      return;
+    }
+
+    // Cannot change owner role
+    const project = await prisma.project.findUnique({ where: { id: str(req.params.id) } });
+    if (project?.ownerId === str(req.params.userId)) {
+      res.status(400).json({ error: 'Cannot change the project owner\'s role' });
+      return;
+    }
+
+    const updated = await prisma.projectMember.update({
+      where: {
+        userId_projectId: { userId: str(req.params.userId), projectId: str(req.params.id) },
+      },
+      data: { role },
+      include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } },
+    });
+
+    res.json({ member: updated });
+  } catch (err) {
+    console.error('Update member role error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
 export default router;

+ 20 - 24
packages/api/src/routes/users.ts

@@ -12,7 +12,7 @@ const str = (v: string | string[] | undefined): string =>
 // GET /api/users — list all users (admin only)
 router.get('/', async (req: Request, res: Response) => {
   try {
-    if (req.user!.role !== 'ADMIN') {
+    if (req.user!.globalRole !== 'ADMIN') {
       res.status(403).json({ error: 'Forbidden — admin only' });
       return;
     }
@@ -22,7 +22,7 @@ router.get('/', async (req: Request, res: Response) => {
         id: true,
         email: true,
         name: true,
-        role: true,
+        globalRole: true,
         avatarUrl: true,
         active: true,
         createdAt: true,
@@ -52,7 +52,7 @@ router.get('/me', async (req: Request, res: Response) => {
         id: true,
         email: true,
         name: true,
-        role: true,
+        globalRole: true,
         avatarUrl: true,
         active: true,
         createdAt: true,
@@ -82,7 +82,6 @@ router.put('/me', async (req: Request, res: Response) => {
   try {
     const { name, avatarUrl, currentPassword, newPassword } = req.body;
 
-    // If changing password, verify current password
     if (newPassword) {
       if (!currentPassword) {
         res.status(400).json({ error: 'currentPassword is required to change password' });
@@ -114,7 +113,7 @@ router.put('/me', async (req: Request, res: Response) => {
           id: true,
           email: true,
           name: true,
-          role: true,
+          globalRole: true,
           avatarUrl: true,
           active: true,
         },
@@ -133,7 +132,7 @@ router.put('/me', async (req: Request, res: Response) => {
         id: true,
         email: true,
         name: true,
-        role: true,
+        globalRole: true,
         avatarUrl: true,
         active: true,
       },
@@ -146,18 +145,18 @@ router.put('/me', async (req: Request, res: Response) => {
   }
 });
 
-// PUT /api/users/:id/role — change user role (admin only)
+// PUT /api/users/:id/role — change user globalRole (admin only)
 router.put('/:id/role', async (req: Request, res: Response) => {
   try {
-    if (req.user!.role !== 'ADMIN') {
+    if (req.user!.globalRole !== 'ADMIN') {
       res.status(403).json({ error: 'Forbidden — admin only' });
       return;
     }
 
     const { role } = req.body;
-    const validRoles = ['ADMIN', 'EDITOR', 'REVIEWER', 'VIEWER'];
+    const validRoles = ['ADMIN', 'MEMBER'];
     if (!validRoles.includes(role)) {
-      res.status(400).json({ error: 'Invalid role. Must be one of: ' + validRoles.join(', ') });
+      res.status(400).json({ error: 'Invalid globalRole. Must be ADMIN or MEMBER' });
       return;
     }
 
@@ -169,21 +168,21 @@ router.put('/:id/role', async (req: Request, res: Response) => {
 
     // Prevent last admin from being demoted
     if (role !== 'ADMIN') {
-      const adminCount = await prisma.user.count({ where: { role: 'ADMIN', active: true } });
-      if (adminCount <= 1 && user.role === 'ADMIN') {
-        res.status(400).json({ error: 'Cannot demote the last 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: { role: role as any },
+      data: { globalRole: role as any },
       select: {
         id: true,
         email: true,
         name: true,
-        role: true,
+        globalRole: true,
         avatarUrl: true,
         active: true,
         createdAt: true,
@@ -192,7 +191,7 @@ router.put('/:id/role', async (req: Request, res: Response) => {
 
     res.json({ user: updated });
   } catch (err) {
-    console.error('Update role error:', err);
+    console.error('Update globalRole error:', err);
     res.status(500).json({ error: 'Internal server error' });
   }
 });
@@ -200,7 +199,7 @@ router.put('/:id/role', async (req: Request, res: Response) => {
 // PUT /api/users/:id/active — activate/deactivate user (admin only)
 router.put('/:id/active', async (req: Request, res: Response) => {
   try {
-    if (req.user!.role !== 'ADMIN') {
+    if (req.user!.globalRole !== 'ADMIN') {
       res.status(403).json({ error: 'Forbidden — admin only' });
       return;
     }
@@ -217,15 +216,13 @@ router.put('/:id/active', async (req: Request, res: Response) => {
       return;
     }
 
-    // Prevent deactivating yourself
     if (user.id === req.user!.userId) {
       res.status(400).json({ error: 'Cannot deactivate your own account' });
       return;
     }
 
-    // Prevent last admin from being deactivated
-    if (!active && user.role === 'ADMIN') {
-      const adminCount = await prisma.user.count({ where: { role: 'ADMIN', active: true } });
+    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;
@@ -239,7 +236,7 @@ router.put('/:id/active', async (req: Request, res: Response) => {
         id: true,
         email: true,
         name: true,
-        role: true,
+        globalRole: true,
         avatarUrl: true,
         active: true,
         createdAt: true,
@@ -256,7 +253,7 @@ router.put('/:id/active', async (req: Request, res: Response) => {
 // DELETE /api/users/:id — delete user (admin only)
 router.delete('/:id', async (req: Request, res: Response) => {
   try {
-    if (req.user!.role !== 'ADMIN') {
+    if (req.user!.globalRole !== 'ADMIN') {
       res.status(403).json({ error: 'Forbidden — admin only' });
       return;
     }
@@ -267,7 +264,6 @@ router.delete('/:id', async (req: Request, res: Response) => {
       return;
     }
 
-    // Prevent deleting yourself
     if (user.id === req.user!.userId) {
       res.status(400).json({ error: 'Cannot delete your own account' });
       return;

+ 1 - 13
packages/api/src/services/ffmpeg.ts

@@ -73,26 +73,14 @@ export async function generateHLS(
 
     ffmpeg(videoPath)
       .outputOptions([
-        // Multiple quality levels
-        '-map 0:v',
-        '-map 0:a?',
-        // Quality level 1: 480p (1500kbps)
-        '-map 0:v? -map 0:a? -c:v:0 libx264 -b:v:0 1500k -maxrate:v:0 1600k -c:a:0 aac -b:a:0 64k',
-        // Quality level 2: 720p (3000kbps)
-        '-map 0:v? -map 0:a? -c:v:1 libx264 -b:v:1 3000k -maxrate:v:1 3200k -c:a:1 aac -b:a:1 96k',
-        // Quality level 3: 1080p (5000kbps)
-        '-map 0:v? -map 0:a? -c:v:2 libx264 -b:v:2 5000k -maxrate:v:2 5400k -c:a:2 aac -b:a:2 128k',
         // HLS settings
         '-f hls',
-        `-hls_time 6`,
+        '-hls_time 6',
         '-hls_playlist_type vod',
         '-hls_segment_filename', path.join(hlsDir, 'segment_%v_%03d.ts'),
         '-master_pl_name', 'master.m3u8',
-        // Use variable bitrate for quality
         '-preset', 'fast',
         '-crf', '23',
-        // Scaling
-        '-vf', 'scale=-2:480,scale=-2:720,scale=-2:1080',
       ])
       .output(outputPattern)
       .on('error', (err) => {

+ 62 - 9
src/app/(auth)/login/page.tsx

@@ -1,18 +1,26 @@
 'use client';
 
-import { useState } from 'react';
-import { useRouter } from 'next/navigation';
+import { useState, useEffect, Suspense } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
 import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
 
-export default function LoginPage() {
+function LoginForm() {
   const router = useRouter();
-  const { login } = useAuth();
+  const searchParams = useSearchParams();
+  const inviteToken = searchParams.get('invite_token');
+  const { login, acceptedProjects, clearAcceptedProjects } = useAuth();
   const [email, setEmail] = useState('');
   const [password, setPassword] = useState('');
   const [error, setError] = useState('');
   const [loading, setLoading] = useState(false);
+  const [justJoined, setJustJoined] = useState(false);
+
+  useEffect(() => {
+    if (acceptedProjects.length > 0 && !justJoined) {
+      setJustJoined(true);
+    }
+  }, [acceptedProjects, justJoined]);
 
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
@@ -20,7 +28,11 @@ export default function LoginPage() {
     setLoading(true);
     try {
       await login(email, password);
-      router.push('/projects');
+      if (inviteToken) {
+        router.push(`/invite/${inviteToken}`);
+      } else {
+        router.push('/projects');
+      }
     } catch (err: unknown) {
       setError(err instanceof Error ? err.message : 'Invalid email or password');
     } finally {
@@ -28,6 +40,11 @@ export default function LoginPage() {
     }
   };
 
+  const handleGoToProject = (projectId: string) => {
+    clearAcceptedProjects();
+    router.push(`/projects/${projectId}`);
+  };
+
   return (
     <div className="min-h-screen flex items-center justify-center relative overflow-hidden"
          style={{ background: 'var(--bg)' }}>
@@ -71,6 +88,24 @@ export default function LoginPage() {
                boxShadow: 'var(--shadow-modal)',
              }}>
 
+          {/* Accepted projects notification */}
+          {justJoined && acceptedProjects.length > 0 && (
+            <div className="mb-5 rounded-lg px-4 py-3 text-sm animate-scale-in"
+                 style={{ background: 'rgba(34,197,94,0.12)', border: '1px solid rgba(34,197,94,0.25)', color: '#86EFAC' }}>
+              <p className="font-medium mb-2">You're now a member of:</p>
+              {acceptedProjects.map(p => (
+                <button
+                  key={p.projectId}
+                  onClick={() => handleGoToProject(p.projectId)}
+                  className="block text-left hover:underline"
+                  style={{ color: '#86EFAC' }}
+                >
+                  → {p.projectName}
+                </button>
+              ))}
+            </div>
+          )}
+
           {/* Heading */}
           <div className="mb-7">
             <h1 className="text-xl font-semibold tracking-tight" style={{ color: 'var(--text)' }}>
@@ -153,9 +188,11 @@ export default function LoginPage() {
           {/* Register link */}
           <p className="text-center text-sm" style={{ color: 'var(--text-muted)' }}>
             No account yet?{' '}
-            <a href="/register"
-               className="font-medium transition-colors hover:underline"
-               style={{ color: '#818CF8' }}>
+            <a
+              href={inviteToken ? `/register?invite_token=${inviteToken}` : '/register'}
+              className="font-medium transition-colors hover:underline"
+              style={{ color: '#818CF8' }}
+            >
               Create workspace
             </a>
           </p>
@@ -176,3 +213,19 @@ export default function LoginPage() {
     </div>
   );
 }
+
+export default function LoginPage() {
+  return (
+    <Suspense fallback={
+      <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
+        <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
+          <div className="w-5 h-5 rounded-full animate-spin"
+               style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
+          <span className="text-sm">Loading…</span>
+        </div>
+      </div>
+    }>
+      <LoginForm />
+    </Suspense>
+  );
+}

+ 105 - 13
src/app/(auth)/register/page.tsx

@@ -1,18 +1,41 @@
 'use client';
 
-import { useState } from 'react';
-import { useRouter } from 'next/navigation';
+import { useState, useEffect, Suspense } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
 import { Button } from '@/components/ui/button';
+import { invitationsApi, InvitationInfo } from '@/lib/api';
 
-export default function RegisterPage() {
+function RegisterForm() {
   const router = useRouter();
-  const { register } = useAuth();
+  const searchParams = useSearchParams();
+  const inviteToken = searchParams.get('invite_token');
+  const { register, acceptedProjects, clearAcceptedProjects } = useAuth();
   const [name, setName] = useState('');
   const [email, setEmail] = useState('');
   const [password, setPassword] = useState('');
   const [error, setError] = useState('');
   const [loading, setLoading] = useState(false);
+  const [inviteInfo, setInviteInfo] = useState<InvitationInfo | null>(null);
+  const [inviteLoading, setInviteLoading] = useState(false);
+  const [justJoined, setJustJoined] = useState(false);
+
+  // Verify invite token if present
+  useEffect(() => {
+    if (inviteToken) {
+      setInviteLoading(true);
+      invitationsApi.verify(inviteToken)
+        .then(({ invitation }) => setInviteInfo(invitation))
+        .catch(() => { /* invalid token, not critical */ })
+        .finally(() => setInviteLoading(false));
+    }
+  }, [inviteToken]);
+
+  useEffect(() => {
+    if (acceptedProjects.length > 0 && !justJoined) {
+      setJustJoined(true);
+    }
+  }, [acceptedProjects, justJoined]);
 
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
@@ -24,7 +47,11 @@ export default function RegisterPage() {
     setLoading(true);
     try {
       await register(email, name, password);
-      router.push('/projects');
+      if (inviteToken) {
+        router.push(`/invite/${inviteToken}`);
+      } else {
+        router.push('/projects');
+      }
     } catch (err: unknown) {
       setError(err instanceof Error ? err.message : 'Registration failed');
     } finally {
@@ -32,6 +59,11 @@ export default function RegisterPage() {
     }
   };
 
+  const handleGoToProject = (projectId: string) => {
+    clearAcceptedProjects();
+    router.push(`/projects/${projectId}`);
+  };
+
   return (
     <div className="min-h-screen flex items-center justify-center relative overflow-hidden"
          style={{ background: 'var(--bg)' }}>
@@ -72,14 +104,49 @@ export default function RegisterPage() {
                boxShadow: 'var(--shadow-modal)',
              }}>
 
-          <div className="mb-7">
-            <h1 className="text-xl font-semibold tracking-tight" style={{ color: 'var(--text)' }}>
-              Create workspace
-            </h1>
-            <p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>
-              Set up your team in under a minute
-            </p>
-          </div>
+          {/* Invite banner */}
+          {inviteLoading ? (
+            <div className="mb-5 rounded-lg px-4 py-3 text-sm animate-fade-in"
+                 style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.20)', color: '#A5B4FC' }}>
+              <div className="flex items-center gap-2">
+                <div className="w-3.5 h-3.5 rounded-full animate-spin"
+                     style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: 1.5 }} />
+                Verifying invitation…
+              </div>
+            </div>
+          ) : inviteInfo ? (
+            <div className="mb-5 rounded-lg px-4 py-3 text-sm animate-scale-in"
+                 style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.20)', color: '#A5B4FC' }}>
+              <div className="flex items-start gap-2.5">
+                <svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
+                </svg>
+                <div>
+                  <p className="font-medium">You're invited to join</p>
+                  <p className="font-semibold mt-0.5">{inviteInfo.projectName}</p>
+                  <span className="badge badge-brand text-[10px] mt-1.5 capitalize">{inviteInfo.role.toLowerCase()}</span>
+                </div>
+              </div>
+            </div>
+          ) : null}
+
+          {/* Accepted projects notification */}
+          {justJoined && acceptedProjects.length > 0 && (
+            <div className="mb-5 rounded-lg px-4 py-3 text-sm animate-scale-in"
+                 style={{ background: 'rgba(34,197,94,0.12)', border: '1px solid rgba(34,197,94,0.25)', color: '#86EFAC' }}>
+              <p className="font-medium mb-2">You're now a member of:</p>
+              {acceptedProjects.map(p => (
+                <button
+                  key={p.projectId}
+                  onClick={() => handleGoToProject(p.projectId)}
+                  className="block text-left hover:underline"
+                  style={{ color: '#86EFAC' }}
+                >
+                  → {p.projectName}
+                </button>
+              ))}
+            </div>
+          )}
 
           {error && (
             <div className="mb-5 rounded-lg px-4 py-3 text-sm flex items-start gap-2.5 animate-scale-in"
@@ -92,6 +159,15 @@ export default function RegisterPage() {
             </div>
           )}
 
+          <div className="mb-7">
+            <h1 className="text-xl font-semibold tracking-tight" style={{ color: 'var(--text)' }}>
+              {inviteInfo ? 'Create your account' : 'Create workspace'}
+            </h1>
+            <p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>
+              {inviteInfo ? 'Join your team in seconds' : 'Set up your team in under a minute'}
+            </p>
+          </div>
+
           <form onSubmit={handleSubmit} className="space-y-4">
             <div>
               <label htmlFor="name" className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
@@ -166,3 +242,19 @@ export default function RegisterPage() {
     </div>
   );
 }
+
+export default function RegisterPage() {
+  return (
+    <Suspense fallback={
+      <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
+        <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
+          <div className="w-5 h-5 rounded-full animate-spin"
+               style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
+          <span className="text-sm">Loading…</span>
+        </div>
+      </div>
+    }>
+      <RegisterForm />
+    </Suspense>
+  );
+}

+ 2 - 2
src/app/(dashboard)/layout.tsx

@@ -89,7 +89,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
 
           {/* Secondary links */}
           <div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
-            {user.role === 'ADMIN' && (
+            {user.globalRole === 'ADMIN' && (
               <NavLink
                 href="/users"
                 active={isActive('/users')}
@@ -125,7 +125,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
             <div className="flex-1 min-w-0">
               <p className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{user.name}</p>
               <p className="text-xs capitalize truncate" style={{ color: 'var(--text-muted)' }}>
-                {user.role.toLowerCase()}
+                {user.globalRole.toLowerCase()}
               </p>
             </div>
             <button

+ 550 - 112
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -3,22 +3,68 @@
 import { useState, useEffect, useCallback } from 'react';
 import { useParams, useRouter } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
-import { projectsApi, assetsApi, Project, Asset } from '@/lib/api';
-import { Modal } from '@/components/ui/modal';
+import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation } from '@/lib/api';
 import { useDropzone } from 'react-dropzone';
 
+const ROLE_COLORS: Record<string, string> = {
+  ADMIN:   'badge-danger',
+  EDITOR:  'badge-brand',
+  REVIEWER:'badge-muted',
+  VIEWER:  'badge-subtle',
+};
+
+const ROLE_LABELS: Record<string, string> = {
+  ADMIN:   'Admin',
+  EDITOR:  'Editor',
+  REVIEWER:'Reviewer',
+  VIEWER:  'Viewer',
+};
+
 export default function ProjectDetailPage() {
   const params = useParams();
   const projectId = params.projectId as string;
-  const { token } = useAuth();
+  const { user, token } = useAuth();
   const router = useRouter();
 
   const [project, setProject] = useState<Project | null>(null);
+  const [members, setMembers] = useState<any[]>([]);
+  const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
   const [assets, setAssets] = useState<Asset[]>([]);
   const [loading, setLoading] = useState(true);
   const [uploading, setUploading] = useState(false);
+  const [activeTab, setActiveTab] = useState<'videos' | 'members'>('videos');
+
+  // Invite form state
+  const [inviteEmail, setInviteEmail] = useState('');
+  const [inviteRole, setInviteRole] = useState('REVIEWER');
+  const [inviting, setInviting] = useState(false);
+  const [inviteError, setInviteError] = useState('');
+  const [inviteSuccess, setInviteSuccess] = useState('');
+
+  // Edit member role
+  const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
+  const [editingRole, setEditingRole] = useState('');
+  const [updatingRole, setUpdatingRole] = useState(false);
+
+  // Remove member
+  const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null);
+  const [removing, setRemoving] = useState(false);
+
+  // Revoke invite
+  const [revokingId, setRevokingId] = useState<string | null>(null);
 
-  const loadData = useCallback(async () => {
+  // Copy link
+  const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
+  const [inviteUrlMap, setInviteUrlMap] = useState<Record<string, string>>({});
+
+  const canManage = members.some(m =>
+    m.user.id === user?.id && ['ADMIN', 'EDITOR'].includes(m.role)
+  );
+  const isAdmin = members.some(m =>
+    m.user.id === user?.id && m.role === 'ADMIN'
+  );
+
+  const loadAll = useCallback(async () => {
     if (!token) return;
     try {
       const [{ project: p }, { assets: a }] = await Promise.all([
@@ -26,15 +72,96 @@ export default function ProjectDetailPage() {
         assetsApi.list(token, projectId),
       ]);
       setProject(p);
+      setMembers(p.members ?? []);
       setAssets(a);
+
+      if (canManage) {
+        const { invitations } = await invitationsApi.list(token, projectId);
+        setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
+      }
     } catch {
       router.push('/projects');
     } finally {
       setLoading(false);
     }
-  }, [token, projectId, router]);
+  }, [token, projectId, router, canManage]);
+
+  useEffect(() => { loadAll(); }, [loadAll]);
 
-  useEffect(() => { loadData(); }, [loadData]);
+  // ── Invite member ──────────────────────────────────────────────────────────
+  const handleInvite = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!token || !inviteEmail.trim()) return;
+    setInviting(true);
+    setInviteError('');
+    setInviteSuccess('');
+    try {
+      const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
+      const { invitations } = await invitationsApi.list(token, projectId);
+      setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
+      setInviteUrlMap(prev => ({ ...prev, [inviteUrl.split('/').pop()!]: inviteUrl }));
+      setInviteEmail('');
+      setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
+      setTimeout(() => setInviteSuccess(''), 3000);
+    } catch (err) {
+      setInviteError(err instanceof Error ? err.message : 'Failed to send invitation');
+    } finally {
+      setInviting(false);
+    }
+  };
+
+  // ── Change role ────────────────────────────────────────────────────────────
+  const handleChangeRole = async (memberId: string) => {
+    if (!token || !editingRole) return;
+    setUpdatingRole(true);
+    try {
+      await projectsApi.updateMember(token, projectId, memberId, editingRole);
+      setMembers(prev => prev.map(m => m.id === memberId ? { ...m, role: editingRole } : m));
+      setEditingRoleId(null);
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to update role');
+    } finally {
+      setUpdatingRole(false);
+    }
+  };
+
+  // ── Remove member ─────────────────────────────────────────────────────────
+  const handleRemoveMember = async () => {
+    if (!token || !confirmRemove) return;
+    setRemoving(true);
+    try {
+      await projectsApi.removeMember(token, projectId, confirmRemove.id);
+      setMembers(prev => prev.filter(m => m.id !== confirmRemove!.id));
+      setConfirmRemove(null);
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to remove member');
+    } finally {
+      setRemoving(false);
+    }
+  };
+
+  // ── Revoke invite ──────────────────────────────────────────────────────────
+  const handleRevoke = async (invitationId: string) => {
+    if (!token) return;
+    setRevokingId(invitationId);
+    try {
+      await invitationsApi.revoke(token, invitationId);
+      setPendingInvites(prev => prev.filter(i => i.id !== invitationId));
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to revoke invitation');
+    } finally {
+      setRevokingId(null);
+    }
+  };
+
+  // ── Copy invite link ──────────────────────────────────────────────────────
+  const handleCopyLink = async (invite: Invitation) => {
+    const base = window.location.origin;
+    const url = inviteUrlMap[invite.token] ?? `${base}/invite/${invite.token}`;
+    await navigator.clipboard.writeText(url);
+    setCopiedInviteId(invite.id);
+    setTimeout(() => setCopiedInviteId(null), 2000);
+  };
 
   const handleDrop = async (acceptedFiles: File[]) => {
     if (!token || acceptedFiles.length === 0) return;
@@ -89,7 +216,7 @@ export default function ProjectDetailPage() {
         <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
           <div className="w-5 h-5 rounded-full animate-spin"
                style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
-          <span className="text-sm">Loading assets…</span>
+          <span className="text-sm">Loading…</span>
         </div>
       </div>
     );
@@ -129,6 +256,28 @@ export default function ProjectDetailPage() {
           )}
         </div>
 
+        {/* Tabs */}
+        <div className="flex items-center gap-1 p-1 rounded-lg"
+             style={{ background: 'rgba(255,255,255,0.04)' }}>
+          {[['videos', 'Videos'], ['members', 'Members']].map(([tab, label]) => (
+            <button key={tab}
+              onClick={() => setActiveTab(tab as any)}
+              className="px-3 py-1.5 rounded-md text-xs font-medium transition-all"
+              style={{
+                background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
+                color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
+              }}>
+              {label}
+              {tab === 'members' && (
+                <span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded-full"
+                      style={{ background: 'rgba(255,255,255,0.06)', color: 'inherit' }}>
+                  {members.length}
+                </span>
+              )}
+            </button>
+          ))}
+        </div>
+
         <div className="text-xs px-2.5 py-1 rounded-full"
              style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
           {assets.length} video{assets.length !== 1 ? 's' : ''}
@@ -137,125 +286,414 @@ export default function ProjectDetailPage() {
 
       <div className="px-8 py-6">
 
-        {/* Upload zone */}
-        <div
-          {...getRootProps()}
-          className="mb-8 rounded-2xl p-10 text-center cursor-pointer transition-all animate-fade-in"
-          style={{
-            background: isDragActive ? 'rgba(99,102,241,0.08)' : 'rgba(255,255,255,0.02)',
-            border: isDragActive
-              ? '1px solid rgba(99,102,241,0.40)'
-              : '1px dashed rgba(255,255,255,0.10)',
-            borderRadius: '16px',
-          }}
-        >
-          <input {...getInputProps()} />
+        {/* ── Videos Tab ───────────────────────────────────────────────────── */}
+        {activeTab === 'videos' && (
+          <>
+            {/* Upload zone */}
+            <div
+              {...getRootProps()}
+              className="mb-8 rounded-2xl p-10 text-center cursor-pointer transition-all animate-fade-in"
+              style={{
+                background: isDragActive ? 'rgba(99,102,241,0.08)' : 'rgba(255,255,255,0.02)',
+                border: isDragActive
+                  ? '1px solid rgba(99,102,241,0.40)'
+                  : '1px dashed rgba(255,255,255,0.10)',
+                borderRadius: '16px',
+              }}
+            >
+              <input {...getInputProps()} />
 
-          {uploading ? (
-            <div className="space-y-3">
-              <div className="w-9 h-9 rounded-full mx-auto animate-spin"
-                   style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
-              <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Uploading…</p>
+              {uploading ? (
+                <div className="space-y-3">
+                  <div className="w-9 h-9 rounded-full mx-auto animate-spin"
+                       style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
+                  <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Uploading…</p>
+                </div>
+              ) : (
+                <>
+                  <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center"
+                       style={{ background: 'rgba(99,102,241,0.10)', border: '1px solid rgba(99,102,241,0.15)' }}>
+                    <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                      <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
+                    </svg>
+                  </div>
+                  <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
+                    {isDragActive ? 'Drop videos here' : 'Drag & drop videos, or click to browse'}
+                  </p>
+                  <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
+                    MP4, MOV, WebM — up to 500MB each
+                  </p>
+                </>
+              )}
             </div>
-          ) : (
-            <>
-              <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center"
-                   style={{ background: 'rgba(99,102,241,0.10)', border: '1px solid rgba(99,102,241,0.15)' }}>
-                <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
-                  <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
-                </svg>
+
+            {/* Asset grid */}
+            {assets.length === 0 ? (
+              <div className="text-center py-20 rounded-2xl animate-fade-in"
+                   style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
+                <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
+                     style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
+                  <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
+                  </svg>
+                </div>
+                <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No videos yet</p>
+                <p className="text-xs" style={{ color: 'var(--text-muted)' }}>Upload your first video using the dropzone above</p>
               </div>
-              <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
-                {isDragActive ? 'Drop videos here' : 'Drag & drop videos, or click to browse'}
-              </p>
-              <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
-                MP4, MOV, WebM — up to 500MB each
-              </p>
-            </>
-          )}
-        </div>
+            ) : (
+              <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
+                {assets.map((asset, i) => (
+                  <a key={asset.id}
+                     href={`/review/${asset.id}`}
+                     className="card overflow-hidden group cursor-pointer"
+                     style={{ animation: `slideUp 0.25s ease-out ${i * 40}ms both` }}>
 
-        {/* Asset grid */}
-        {assets.length === 0 ? (
-          <div className="text-center py-20 rounded-2xl animate-fade-in"
-               style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
-            <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
-                 style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
-              <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
-                <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
-              </svg>
-            </div>
-            <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No videos yet</p>
-            <p className="text-xs" style={{ color: 'var(--text-muted)' }}>Upload your first video using the dropzone above</p>
-          </div>
-        ) : (
-          <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
-            {assets.map((asset, i) => (
-              <a key={asset.id}
-                 href={`/review/${asset.id}`}
-                 className="card overflow-hidden group cursor-pointer"
-                 style={{ animation: `slideUp 0.25s ease-out ${i * 40}ms both` }}>
-
-                {/* Thumbnail */}
-                <div className="relative aspect-video" style={{ background: '#080810' }}>
-                  {asset.thumbnail ? (
-                    <img
-                      src={`/uploads/${asset.thumbnail}`}
-                      alt={asset.title}
-                      className="w-full h-full object-cover"
-                      style={{ opacity: 0.85 }}
-                    />
-                  ) : (
-                    <div className="w-full h-full flex items-center justify-center">
-                      <svg className="w-10 h-10" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
-                        <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
-                        <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
-                      </svg>
+                    {/* Thumbnail */}
+                    <div className="relative aspect-video" style={{ background: '#080810' }}>
+                      {asset.thumbnail ? (
+                        <img
+                          src={`/uploads/${asset.thumbnail}`}
+                          alt={asset.title}
+                          className="w-full h-full object-cover"
+                          style={{ opacity: 0.85 }}
+                        />
+                      ) : (
+                        <div className="w-full h-full flex items-center justify-center">
+                          <svg className="w-10 h-10" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
+                            <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
+                            <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                          </svg>
+                        </div>
+                      )}
+
+                      {asset.duration && (
+                        <span className="absolute bottom-2 right-2 text-xs px-1.5 py-0.5 rounded-md font-mono"
+                              style={{ background: 'rgba(0,0,0,0.70)', color: '#E2E8F0' }}>
+                          {formatDuration(asset.duration)}
+                        </span>
+                      )}
+
+                      <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"
+                           style={{ background: 'rgba(0,0,0,0.35)' }}>
+                        <div className="w-12 h-12 rounded-full flex items-center justify-center"
+                             style={{ background: 'rgba(99,102,241,0.80)', boxShadow: '0 0 24px rgba(99,102,241,0.4)' }}>
+                          <svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
+                            <path d="M8 5v14l11-7z" />
+                          </svg>
+                        </div>
+                      </div>
                     </div>
-                  )}
-
-                  {/* Duration */}
-                  {asset.duration && (
-                    <span className="absolute bottom-2 right-2 text-xs px-1.5 py-0.5 rounded-md font-mono"
-                          style={{ background: 'rgba(0,0,0,0.70)', color: '#E2E8F0' }}>
-                      {formatDuration(asset.duration)}
-                    </span>
-                  )}
-
-                  {/* Play overlay */}
-                  <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"
-                       style={{ background: 'rgba(0,0,0,0.35)' }}>
-                    <div className="w-12 h-12 rounded-full flex items-center justify-center"
-                         style={{ background: 'rgba(99,102,241,0.80)', boxShadow: '0 0 24px rgba(99,102,241,0.4)' }}>
-                      <svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
-                        <path d="M8 5v14l11-7z" />
-                      </svg>
+
+                    {/* Info */}
+                    <div className="p-4">
+                      <div className="flex items-start justify-between gap-2 mb-2">
+                        <h3 className="text-sm font-medium truncate flex-1 transition-colors"
+                            style={{ color: 'var(--text)' }}>
+                          {asset.title}
+                        </h3>
+                        <span className={`badge shrink-0 ${statusColors[asset.status]}`}>
+                          {statusLabels[asset.status]}
+                        </span>
+                      </div>
+                      <div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-muted)' }}>
+                        <span>{(asset as any)._count?.comments ?? 0} comment{((asset as any)._count?.comments ?? 0) !== 1 ? 's' : ''}</span>
+                        <span className="w-1 h-1 rounded-full" style={{ background: 'rgba(255,255,255,0.12)' }} />
+                        <span>{new Date(asset.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
+                      </div>
                     </div>
+                  </a>
+                ))}
+              </div>
+            )}
+          </>
+        )}
+
+        {/* ── Members Tab ─────────────────────────────────────────────────── */}
+        {activeTab === 'members' && (
+          <div className="max-w-3xl animate-fade-in">
+
+            {/* Invite form */}
+            {canManage && (
+              <div className="card p-5 mb-6">
+                <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
+                  Invite someone
+                </h2>
+
+                <form onSubmit={handleInvite} className="flex items-end gap-3 flex-wrap">
+                  <div className="flex-1 min-w-[180px]">
+                    <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email address</label>
+                    <input
+                      type="email"
+                      className="input"
+                      value={inviteEmail}
+                      onChange={e => setInviteEmail(e.target.value)}
+                      placeholder="colleague@company.com"
+                      required
+                    />
+                  </div>
+                  <div className="w-36">
+                    <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
+                    <select
+                      className="input"
+                      value={inviteRole}
+                      onChange={e => setInviteRole(e.target.value)}
+                    >
+                      {Object.entries(ROLE_LABELS).map(([value, label]) => (
+                        <option key={value} value={value}>{label}</option>
+                      ))}
+                    </select>
                   </div>
+                  <button
+                    type="submit"
+                    disabled={inviting}
+                    className="btn btn-primary btn-md"
+                  >
+                    {inviting ? 'Sending…' : 'Send invite'}
+                  </button>
+                </form>
+
+                {inviteError && (
+                  <p className="text-xs mt-2" style={{ color: '#F87171' }}>{inviteError}</p>
+                )}
+                {inviteSuccess && (
+                  <p className="text-xs mt-2" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>
+                )}
+              </div>
+            )}
+
+            {/* Members list */}
+            <div className="card overflow-hidden mb-6">
+              <div className="px-5 py-4 border-b" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
+                <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
+                  Members ({members.length})
+                </h2>
+              </div>
+
+              {members.length === 0 ? (
+                <div className="p-8 text-center">
+                  <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No members yet</p>
                 </div>
+              ) : (
+                <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
+                  {members.map(m => {
+                    const isMe = m.user.id === user?.id;
+                    const canEdit = isAdmin && !isMe;
 
-                {/* Info */}
-                <div className="p-4">
-                  <div className="flex items-start justify-between gap-2 mb-2">
-                    <h3 className="text-sm font-medium truncate flex-1 transition-colors"
-                        style={{ color: 'var(--text)' }}>
-                      {asset.title}
-                    </h3>
-                    <span className={`badge shrink-0 ${statusColors[asset.status]}`}>
-                      {statusLabels[asset.status]}
-                    </span>
+                    return (
+                      <div key={m.id}
+                           className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
+
+                        {/* Avatar */}
+                        <div className="w-9 h-9 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
+                             style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
+                          {m.user.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
+                        </div>
+
+                        {/* Info */}
+                        <div className="flex-1 min-w-0">
+                          <div className="flex items-center gap-2">
+                            <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
+                              {m.user.name}
+                              {isMe && <span className="ml-1.5 text-[10px]" style={{ color: 'var(--text-subtle)' }}>(you)</span>}
+                            </span>
+                          </div>
+                          <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
+                        </div>
+
+                        {/* Joined date */}
+                        <span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
+                          {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
+                        </span>
+
+                        {/* Role */}
+                        {editingRoleId === m.id ? (
+                          <div className="flex items-center gap-2 shrink-0">
+                            <select
+                              className="input text-xs py-1.5"
+                              value={editingRole}
+                              onChange={e => setEditingRole(e.target.value)}
+                              autoFocus
+                            >
+                              {Object.entries(ROLE_LABELS).map(([v, l]) => (
+                                <option key={v} value={v}>{l}</option>
+                              ))}
+                            </select>
+                            <button
+                              onClick={() => handleChangeRole(m.id)}
+                              disabled={updatingRole}
+                              className="btn btn-primary btn-sm px-2"
+                              title="Save"
+                            >
+                              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                              </svg>
+                            </button>
+                            <button
+                              onClick={() => setEditingRoleId(null)}
+                              className="btn btn-secondary btn-sm px-2"
+                              title="Cancel"
+                            >
+                              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+                              </svg>
+                            </button>
+                          </div>
+                        ) : (
+                          <div className="flex items-center gap-2 shrink-0">
+                            <span className={`badge ${ROLE_COLORS[m.role] ?? 'badge-muted'}`}>
+                              {ROLE_LABELS[m.role] ?? m.role}
+                            </span>
+                            {canEdit && (
+                              <button
+                                onClick={() => { setEditingRoleId(m.id); setEditingRole(m.role); }}
+                                className="btn btn-secondary btn-sm"
+                                title="Change role"
+                              >
+                                <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                  <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
+                                </svg>
+                              </button>
+                            )}
+                            {isAdmin && !isMe && (
+                              <button
+                                onClick={() => setConfirmRemove({ id: m.user.id, name: m.user.name })}
+                                className="btn btn-danger btn-sm"
+                                title="Remove from project"
+                              >
+                                <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                  <path strokeLinecap="round" strokeLinejoin="round" d="M22 12h-4l-3 9L9 3l-3 9H2" />
+                                </svg>
+                              </button>
+                            )}
+                          </div>
+                        )}
+                      </div>
+                    );
+                  })}
+                </div>
+              )}
+            </div>
+
+            {/* Pending invitations */}
+            {canManage && (
+              <div className="card overflow-hidden">
+                <div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
+                  <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
+                    Pending invitations
+                  </h2>
+                  <span className="text-xs px-2 py-0.5 rounded-full"
+                        style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
+                    {pendingInvites.length}
+                  </span>
+                </div>
+
+                {pendingInvites.length === 0 ? (
+                  <div className="p-8 text-center">
+                    <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>No pending invitations</p>
                   </div>
-                  <div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-muted)' }}>
-                    <span>{asset._count?.comments ?? 0} comment{(asset._count?.comments ?? 0) !== 1 ? 's' : ''}</span>
-                    <span className="w-1 h-1 rounded-full" style={{ background: 'rgba(255,255,255,0.12)' }} />
-                    <span>{new Date(asset.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
+                ) : (
+                  <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
+                    {pendingInvites.map(inv => (
+                      <div key={inv.id}
+                           className="flex items-center gap-4 px-5 py-4">
+
+                        {/* Icon */}
+                        <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
+                             style={{ background: 'rgba(99,102,241,0.08)' }}>
+                          <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                            <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
+                          </svg>
+                        </div>
+
+                        {/* Info */}
+                        <div className="flex-1 min-w-0">
+                          <div className="flex items-center gap-2">
+                            <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
+                            <span className={`badge ${ROLE_COLORS[inv.role] ?? 'badge-muted'}`}>
+                              {ROLE_LABELS[inv.role] ?? inv.role}
+                            </span>
+                          </div>
+                          <div className="flex items-center gap-3 mt-0.5 text-xs" style={{ color: 'var(--text-subtle)' }}>
+                            <span>Sent {new Date(inv.createdAt).toLocaleDateString()}</span>
+                            <span>·</span>
+                            <span>Expires {new Date(inv.expiresAt).toLocaleDateString()}</span>
+                          </div>
+                        </div>
+
+                        {/* Actions */}
+                        <div className="flex items-center gap-1.5 shrink-0">
+                          <button
+                            onClick={() => handleCopyLink(inv)}
+                            className="btn btn-secondary btn-sm"
+                            title="Copy invite link"
+                          >
+                            {copiedInviteId === inv.id ? (
+                              <svg className="w-3.5 h-3.5" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                              </svg>
+                            ) : (
+                              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
+                              </svg>
+                            )}
+                          </button>
+                          <button
+                            onClick={() => handleRevoke(inv.id)}
+                            disabled={revokingId === inv.id}
+                            className="btn btn-danger btn-sm"
+                            title="Revoke invitation"
+                          >
+                            {revokingId === inv.id ? '…' : (
+                              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+                              </svg>
+                            )}
+                          </button>
+                        </div>
+                      </div>
+                    ))}
                   </div>
-                </div>
-              </a>
-            ))}
+                )}
+
+                {pendingInvites.length > 0 && (
+                  <div className="px-5 py-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
+                    <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>
+                      Invitation links expire after 7 days. Copy the link and send it manually, or ask the recipient to check their email.
+                    </p>
+                  </div>
+                )}
+              </div>
+            )}
           </div>
         )}
       </div>
+
+      {/* Remove member confirm modal */}
+      {confirmRemove && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center"
+             style={{ background: 'rgba(0,0,0,0.7)' }}>
+          <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
+            <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text)' }}>
+              Remove {confirmRemove.name}?
+            </h3>
+            <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
+              They'll lose access to this project and all its videos. They can rejoin if invited again.
+            </p>
+            <div className="flex gap-3 justify-end">
+              <button onClick={() => setConfirmRemove(null)} className="btn btn-secondary btn-md">
+                Cancel
+              </button>
+              <button
+                onClick={handleRemoveMember}
+                disabled={removing}
+                className="btn btn-danger btn-md"
+              >
+                {removing ? 'Removing…' : 'Remove'}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }

+ 2 - 2
src/app/(dashboard)/settings/page.tsx

@@ -92,7 +92,7 @@ export default function SettingsPage() {
               <div>
                 <p className="text-sm font-medium" style={{ color: 'var(--text)' }}>{user.email}</p>
                 <p className="text-xs mt-0.5 capitalize" style={{ color: 'var(--text-muted)' }}>
-                  {user.role.toLowerCase()}
+                  {user.globalRole.toLowerCase()}
                 </p>
               </div>
             </div>
@@ -201,7 +201,7 @@ export default function SettingsPage() {
             </div>
             <div className="flex justify-between">
               <span style={{ color: 'var(--text-muted)' }}>Role</span>
-              <span className="badge badge-brand capitalize">{user.role.toLowerCase()}</span>
+              <span className="badge badge-brand capitalize">{user.globalRole.toLowerCase()}</span>
             </div>
           </div>
         </section>

+ 13 - 15
src/app/(dashboard)/users/page.tsx

@@ -4,11 +4,9 @@ import { useState, useEffect, useCallback } from 'react';
 import { useAuth } from '@/lib/auth-context';
 import { usersApi, AdminUser } from '@/lib/api';
 
-const ROLE_CONFIG: Record<string, { label: string; badge: string }> = {
-  ADMIN:    { label: 'Admin',    badge: 'badge-danger' },
-  EDITOR:   { label: 'Editor',   badge: 'badge-brand' },
-  REVIEWER: { label: 'Reviewer', badge: 'badge-warning' },
-  VIEWER:   { label: 'Viewer',   badge: 'badge-muted' },
+const GLOBAL_ROLE_CONFIG: Record<string, { label: string; badge: string }> = {
+  ADMIN:  { label: 'Admin',  badge: 'badge-danger' },
+  MEMBER: { label: 'Member', badge: 'badge-muted' },
 };
 
 export default function UsersPage() {
@@ -19,7 +17,7 @@ export default function UsersPage() {
   const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
 
   const loadUsers = useCallback(async () => {
-    if (!token || currentUser?.role !== 'ADMIN') return;
+    if (!token || currentUser?.globalRole !== 'ADMIN') return;
     try {
       const { users: u } = await usersApi.list(token);
       setUsers(u);
@@ -28,16 +26,16 @@ export default function UsersPage() {
     } finally {
       setLoading(false);
     }
-  }, [token, currentUser?.role]);
+  }, [token, currentUser?.globalRole]);
 
   useEffect(() => { loadUsers(); }, [loadUsers]);
 
-  const handleRoleChange = async (userId: string, role: string) => {
+  const handleGlobalRoleChange = async (userId: string, globalRole: string) => {
     if (!token) return;
     setUpdating(userId);
     try {
-      const { user: updated } = await usersApi.updateRole(token, userId, role);
-      setUsers(prev => prev.map(u => u.id === userId ? { ...u, role: updated.role } : u));
+      const { user: updated } = await usersApi.updateRole(token, userId, globalRole);
+      setUsers(prev => prev.map(u => u.id === userId ? { ...u, globalRole: (updated as any).globalRole ?? globalRole } : u));
     } catch (err) {
       alert(err instanceof Error ? err.message : 'Failed to update role');
     } finally {
@@ -69,7 +67,7 @@ export default function UsersPage() {
     }
   };
 
-  if (currentUser?.role !== 'ADMIN') {
+  if (currentUser?.globalRole !== 'ADMIN') {
     return (
       <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
         <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Admin access required</p>
@@ -110,7 +108,7 @@ export default function UsersPage() {
         ) : (
           <div className="space-y-3">
             {users.map(u => {
-              const roleCfg = ROLE_CONFIG[u.role] ?? ROLE_CONFIG.VIEWER;
+              const roleCfg = GLOBAL_ROLE_CONFIG[u.globalRole] ?? GLOBAL_ROLE_CONFIG.MEMBER;
               const isMe = u.id === currentUser?.id;
 
               return (
@@ -143,13 +141,13 @@ export default function UsersPage() {
                   {/* Role selector */}
                   <div className="shrink-0">
                     <select
-                      value={u.role}
-                      onChange={e => handleRoleChange(u.id, e.target.value)}
+                      value={u.globalRole}
+                      onChange={e => handleGlobalRoleChange(u.id, e.target.value)}
                       disabled={updating === u.id || isMe}
                       className="input text-xs py-1.5 pr-6"
                       style={{ width: 'auto', minWidth: 0 }}
                     >
-                      {Object.entries(ROLE_CONFIG).map(([value, cfg]) => (
+                      {Object.entries(GLOBAL_ROLE_CONFIG).map(([value, cfg]) => (
                         <option key={value} value={value}>{cfg.label}</option>
                       ))}
                     </select>

+ 198 - 0
src/app/invite/[token]/page.tsx

@@ -0,0 +1,198 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import { invitationsApi, InvitationInfo } from '@/lib/api';
+import { useAuth } from '@/lib/auth-context';
+
+export default function InvitePage() {
+  const params = useParams();
+  const token = params.token as string;
+  const router = useRouter();
+  const { user, token: authToken } = useAuth();
+
+  const [invitation, setInvitation] = useState<InvitationInfo | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [accepting, setAccepting] = useState(false);
+  const [accepted, setAccepted] = useState(false);
+
+  useEffect(() => {
+    invitationsApi.verify(token)
+      .then(({ invitation: inv }) => setInvitation(inv))
+      .catch(() => setError('This invitation is invalid or has expired.'))
+      .finally(() => setLoading(false));
+  }, [token]);
+
+  const handleAccept = async () => {
+    if (!authToken) {
+      // Redirect to login with invite token, come back after
+      router.push(`/login?invite_token=${token}`);
+      return;
+    }
+
+    setAccepting(true);
+    try {
+      await invitationsApi.accept(token);
+      setAccepted(true);
+      // Refresh projects list after a short delay
+      setTimeout(() => router.push(`/projects`), 1500);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to accept invitation');
+    } finally {
+      setAccepting(false);
+    }
+  };
+
+  if (loading) {
+    return (
+      <div className="min-h-screen flex items-center justify-center" style={{ background: '#0A0B14' }}>
+        <div className="text-center">
+          <div className="w-8 h-8 rounded-full animate-spin mx-auto mb-4"
+               style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: 2 }} />
+          <p className="text-sm" style={{ color: '#6B7280' }}>Verifying invitation…</p>
+        </div>
+      </div>
+    );
+  }
+
+  if (error && !invitation) {
+    return (
+      <div className="min-h-screen flex items-center justify-center" style={{ background: '#0A0B14' }}>
+        <div className="card p-8 max-w-sm w-full mx-4 text-center">
+          <div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-4"
+               style={{ background: 'rgba(239,68,68,0.1)' }}>
+            <svg className="w-6 h-6" style={{ color: '#EF4444' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+            </svg>
+          </div>
+          <h1 className="text-lg font-semibold mb-2" style={{ color: '#F9FAFB' }}>Invalid Invitation</h1>
+          <p className="text-sm mb-6" style={{ color: '#6B7280' }}>{error}</p>
+          <button onClick={() => router.push('/projects')} className="btn btn-primary btn-md w-full">
+            Go to Projects
+          </button>
+        </div>
+      </div>
+    );
+  }
+
+  if (accepted) {
+    return (
+      <div className="min-h-screen flex items-center justify-center" style={{ background: '#0A0B14' }}>
+        <div className="card p-8 max-w-sm w-full mx-4 text-center">
+          <div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-4"
+               style={{ background: 'rgba(34,197,94,0.1)' }}>
+            <svg className="w-6 h-6" style={{ color: '#22C55E' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
+            </svg>
+          </div>
+          <h1 className="text-lg font-semibold mb-2" style={{ color: '#F9FAFB' }}>Welcome!</h1>
+          <p className="text-sm" style={{ color: '#6B7280' }}>
+            You've joined <strong style={{ color: '#F9FAFB' }}>{invitation?.projectName}</strong>. Redirecting…
+          </p>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="min-h-screen flex items-center justify-center" style={{ background: '#0A0B14' }}>
+      <div className="card p-8 max-w-sm w-full mx-4">
+        {/* Project icon */}
+        <div className="w-12 h-12 rounded-2xl flex items-center justify-center mx-auto mb-4"
+             style={{ background: 'rgba(99,102,241,0.1)' }}>
+          <svg className="w-6 h-6" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
+              d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
+          </svg>
+        </div>
+
+        <h1 className="text-lg font-semibold text-center mb-1" style={{ color: '#F9FAFB' }}>
+          You're invited
+        </h1>
+        <p className="text-sm text-center mb-1" style={{ color: '#9CA3AF' }}>
+          to join project
+        </p>
+        <p className="text-xl font-bold text-center mb-6" style={{ color: '#818CF8' }}>
+          {invitation?.projectName}
+        </p>
+
+        {/* Role badge */}
+        <div className="flex justify-center mb-6">
+          <span className="badge badge-brand capitalize">
+            {invitation?.role.toLowerCase()}
+          </span>
+        </div>
+
+        {invitation?.alreadyMember ? (
+          <div className="text-center">
+            <p className="text-sm mb-4" style={{ color: '#6B7280' }}>
+              You're already a member of this project.
+            </p>
+            <button onClick={() => router.push('/projects')} className="btn btn-primary btn-md w-full">
+              Go to Projects
+            </button>
+          </div>
+        ) : !user ? (
+          <div className="text-center">
+            <p className="text-sm mb-4" style={{ color: '#6B7280' }}>
+              Create an account or sign in to accept this invitation.
+            </p>
+            <div className="space-y-2">
+              <button onClick={handleAccept} className="btn btn-primary btn-md w-full">
+                Sign in to accept
+              </button>
+              <button
+                onClick={() => router.push(`/register?invite_token=${token}`)}
+                className="btn btn-secondary btn-md w-full"
+              >
+                Create account
+              </button>
+            </div>
+          </div>
+        ) : invitation?.isOwnInvitation ? (
+          <div className="text-center">
+            <p className="text-sm mb-4" style={{ color: '#6B7280' }}>
+              This invitation was sent to <strong style={{ color: '#F9FAFB' }}>{user.email}</strong>
+            </p>
+            <button
+              onClick={handleAccept}
+              disabled={accepting}
+              className="btn btn-primary btn-md w-full"
+            >
+              {accepting ? 'Joining…' : 'Accept & Join Project'}
+            </button>
+          </div>
+        ) : (
+          <div className="text-center">
+            <div className="w-8 h-8 rounded-full flex items-center justify-center mx-auto mb-3"
+                 style={{ background: 'rgba(239,68,68,0.1)' }}>
+              <svg className="w-4 h-4" style={{ color: '#EF4444' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+              </svg>
+            </div>
+            <p className="text-sm mb-2" style={{ color: '#F87171' }}>Email mismatch</p>
+            <p className="text-xs mb-4" style={{ color: '#6B7280' }}>
+              This invitation was sent to <strong>{invitation?.email}</strong>.<br/>
+              You're currently logged in as <strong>{user.email}</strong>.
+            </p>
+            <p className="text-xs" style={{ color: '#4B5563' }}>
+              Sign in with the correct account, or ask the project admin to resend the invitation.
+            </p>
+          </div>
+        )}
+
+        {/* Expiry */}
+        {invitation?.expiresAt && (
+          <p className="text-xs text-center mt-6" style={{ color: '#4B5563' }}>
+            Expires {new Date(invitation.expiresAt).toLocaleDateString()}
+          </p>
+        )}
+
+        {error && (
+          <p className="text-xs text-center mt-3" style={{ color: '#F87171' }}>{error}</p>
+        )}
+      </div>
+    </div>
+  );
+}

+ 6 - 0
src/app/review/[assetId]/page.tsx

@@ -294,6 +294,11 @@ export default function ReviewPage() {
   const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
   const visibleComments = showResolved ? comments : comments.filter(c => !c.resolved);
 
+  // Flatten all comment annotations with their timestamps for the player to display
+  const visibleAnnotations = allComments.flatMap(c =>
+    (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
+  );
+
   if (loading) {
     return (
       <div className="h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
@@ -391,6 +396,7 @@ export default function ReviewPage() {
             mimeType={asset.mimeType}
             fps={fps}
             comments={allComments}
+            visibleAnnotations={visibleAnnotations}
             drawMode={drawMode}
             drawTool={drawTool}
             drawColor={drawColor}

+ 49 - 52
src/components/video-player/AnnotationCanvas.tsx

@@ -1,9 +1,9 @@
 'use client';
 
-import { useRef, useEffect, useCallback } from 'react';
+import { useRef, useEffect } from 'react';
 import { AnnotationData } from '../../lib/api';
 
-const COLORS = [
+export const COLORS = [
   { name: 'Red',    value: '#ef4444' },
   { name: 'Orange', value: '#f97316' },
   { name: 'Yellow', value: '#eab308' },
@@ -13,7 +13,53 @@ const COLORS = [
   { name: 'White',  value: '#ffffff' },
 ];
 
-type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse';
+export type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse';
+
+// ── Standalone render function (used by both draw canvas and display canvas) ──
+export function drawShape(ctx: CanvasRenderingContext2D, ann: AnnotationData) {
+  ctx.save();
+  ctx.strokeStyle = ann.color;
+  ctx.fillStyle = ann.color;
+  ctx.lineWidth = 3;
+  ctx.lineCap = 'round';
+  ctx.lineJoin = 'round';
+
+  if (ann.type === 'pen' && ann.points && ann.points.length >= 2) {
+    ctx.beginPath();
+    ctx.moveTo(ann.points[0][0] * ctx.canvas.width, ann.points[0][1] * ctx.canvas.height);
+    for (let i = 1; i < ann.points.length; i++) {
+      ctx.lineTo(ann.points[i][0] * ctx.canvas.width, ann.points[i][1] * ctx.canvas.height);
+    }
+    ctx.stroke();
+  } else if (ann.type === 'arrow' && ann.points && ann.points.length >= 2) {
+    const [x1, y1] = ann.points[0];
+    const [x2, y2] = ann.points[ann.points.length - 1];
+    const sx = x1 * ctx.canvas.width, sy = y1 * ctx.canvas.height;
+    const ex = x2 * ctx.canvas.width, ey = y2 * ctx.canvas.height;
+    const angle = Math.atan2(ey - sy, ex - sx);
+    const headLen = 16;
+    ctx.beginPath();
+    ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
+    ctx.beginPath();
+    ctx.moveTo(ex, ey);
+    ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6));
+    ctx.moveTo(ex, ey);
+    ctx.lineTo(ex - headLen * Math.cos(angle + Math.PI / 6), ey - headLen * Math.sin(angle + Math.PI / 6));
+    ctx.stroke();
+  } else if (ann.type === 'rect' && ann.boundingBox) {
+    const { x, y, width: w, height: h } = ann.boundingBox;
+    ctx.strokeRect(x * ctx.canvas.width, y * ctx.canvas.height, w * ctx.canvas.width, h * ctx.canvas.height);
+  } else if (ann.type === 'ellipse' && ann.boundingBox) {
+    const { x, y, width: w, height: h } = ann.boundingBox;
+    ctx.beginPath();
+    ctx.ellipse(
+      (x + w / 2) * ctx.canvas.width, (y + h / 2) * ctx.canvas.height,
+      (w / 2) * ctx.canvas.width, (h / 2) * ctx.canvas.height, 0, 0, 2 * Math.PI
+    );
+    ctx.stroke();
+  }
+  ctx.restore();
+}
 
 interface Props {
   isActive: boolean;
@@ -44,52 +90,6 @@ export function AnnotationCanvas({
   const isDrawingRef = useRef(false);
   const drawRef = useRef<DrawState | null>(null);
 
-  // ── Draw a single annotation onto ctx ──────────────────────────────────────
-  function drawShape(ctx: CanvasRenderingContext2D, ann: AnnotationData) {
-    ctx.save();
-    ctx.strokeStyle = ann.color;
-    ctx.fillStyle = ann.color;
-    ctx.lineWidth = 3;
-    ctx.lineCap = 'round';
-    ctx.lineJoin = 'round';
-
-    if (ann.type === 'pen' && ann.points && ann.points.length >= 2) {
-      ctx.beginPath();
-      ctx.moveTo(ann.points[0][0] * ctx.canvas.width, ann.points[0][1] * ctx.canvas.height);
-      for (let i = 1; i < ann.points.length; i++) {
-        ctx.lineTo(ann.points[i][0] * ctx.canvas.width, ann.points[i][1] * ctx.canvas.height);
-      }
-      ctx.stroke();
-    } else if (ann.type === 'arrow' && ann.points && ann.points.length >= 2) {
-      const [x1, y1] = ann.points[0];
-      const [x2, y2] = ann.points[ann.points.length - 1];
-      const sx = x1 * ctx.canvas.width, sy = y1 * ctx.canvas.height;
-      const ex = x2 * ctx.canvas.width, ey = y2 * ctx.canvas.height;
-      const angle = Math.atan2(ey - sy, ex - sx);
-      const headLen = 16;
-      ctx.beginPath();
-      ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
-      ctx.beginPath();
-      ctx.moveTo(ex, ey);
-      ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6));
-      ctx.moveTo(ex, ey);
-      ctx.lineTo(ex - headLen * Math.cos(angle + Math.PI / 6), ey - headLen * Math.sin(angle + Math.PI / 6));
-      ctx.stroke();
-    } else if (ann.type === 'rect' && ann.boundingBox) {
-      const { x, y, width: w, height: h } = ann.boundingBox;
-      ctx.strokeRect(x * ctx.canvas.width, y * ctx.canvas.height, w * ctx.canvas.width, h * ctx.canvas.height);
-    } else if (ann.type === 'ellipse' && ann.boundingBox) {
-      const { x, y, width: w, height: h } = ann.boundingBox;
-      ctx.beginPath();
-      ctx.ellipse(
-        (x + w / 2) * ctx.canvas.width, (y + h / 2) * ctx.canvas.height,
-        (w / 2) * ctx.canvas.width, (h / 2) * ctx.canvas.height, 0, 0, 2 * Math.PI
-      );
-      ctx.stroke();
-    }
-    ctx.restore();
-  }
-
   // ── Full canvas redraw (clear + live stroke) ─────────────────────────────────
   function redraw() {
     const canvas = canvasRef.current;
@@ -186,6 +186,3 @@ export function AnnotationCanvas({
     />
   );
 }
-
-export { COLORS };
-export type { Tool };

+ 46 - 2
src/components/video-player/VideoPlayer.tsx

@@ -2,17 +2,22 @@
 
 import { useRef, useState, useEffect, useCallback } from 'react';
 import Hls from 'hls.js';
-import { AnnotationCanvas, COLORS } from './AnnotationCanvas';
+import { AnnotationCanvas, COLORS, drawShape, Tool } from './AnnotationCanvas';
 import { Timeline } from './Timeline';
 import { AnnotationData, Comment } from '@/lib/api';
 
-type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse';
+interface AnnotationWithTimestamp {
+  annotation: AnnotationData;
+  timestamp: number;
+}
 
 interface Props {
   src: string;
   mimeType: string;
   fps?: number;
   comments: Comment[];
+  // Annotations to display during playback (not in draw mode)
+  visibleAnnotations: AnnotationWithTimestamp[];
   // Draw mode — controlled externally by parent
   drawMode: boolean;
   drawTool: Tool;
@@ -33,6 +38,7 @@ export function VideoPlayer({
   mimeType,
   fps = 30,
   comments,
+  visibleAnnotations,
   drawMode,
   drawTool,
   drawColor,
@@ -45,6 +51,7 @@ export function VideoPlayer({
 }: Props) {
   const videoRef = useRef<HTMLVideoElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
+  const displayCanvasRef = useRef<HTMLCanvasElement>(null);
   const [playing, setPlaying] = useState(false);
   const [currentTime, setCurrentTime] = useState(0);
   const [duration, setDuration] = useState(0);
@@ -85,6 +92,36 @@ export function VideoPlayer({
     return () => obs.disconnect();
   }, []);
 
+  // Resize + redraw display canvas when dims or annotations change
+  useEffect(() => {
+    const canvas = displayCanvasRef.current;
+    if (!canvas) return;
+    canvas.width = dims.width;
+    canvas.height = dims.height;
+    redrawAnnotations();
+  }, [dims, visibleAnnotations]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  // Redraw annotations on every time update
+  useEffect(() => {
+    redrawAnnotations();
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [currentTime]);
+
+  function redrawAnnotations() {
+    const canvas = displayCanvasRef.current;
+    if (!canvas) return;
+    const ctx = canvas.getContext('2d');
+    if (!ctx) return;
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+    const frameRange = 3 / (fps || 30); // ±3 frames in seconds
+    for (const { annotation, timestamp } of visibleAnnotations) {
+      if (Math.abs(currentTime - timestamp) <= frameRange) {
+        drawShape(ctx, annotation);
+      }
+    }
+  }
+
   // Keyboard shortcuts
   useEffect(() => {
     const handleKey = (e: KeyboardEvent) => {
@@ -206,6 +243,13 @@ export function VideoPlayer({
         playsInline
       />
 
+      {/* Annotation display layer — shows saved annotations during playback */}
+      <canvas
+        ref={displayCanvasRef}
+        className="absolute inset-0 z-[5] pointer-events-none"
+        style={{ display: drawMode ? 'none' : 'block' }}
+      />
+
       {/* Annotation drawing layer — only active when drawMode */}
       <AnnotationCanvas
         isActive={drawMode}

+ 77 - 4
src/lib/api.ts

@@ -37,13 +37,13 @@ async function apiFetch<T = unknown>(
 
 export const authApi = {
   register: (data: { email: string; name: string; password: string }) =>
-    apiFetch<{ user: User; token: string }>('/api/auth/register', {
+    apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[] }>('/api/auth/register', {
       method: 'POST',
       body: JSON.stringify(data),
     }),
 
   login: (data: { email: string; password: string }) =>
-    apiFetch<{ user: User; token: string }>('/api/auth/login', {
+    apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[] }>('/api/auth/login', {
       method: 'POST',
       body: JSON.stringify(data),
     }),
@@ -87,6 +87,16 @@ export const projectsApi = {
       body: JSON.stringify({ email, role }),
       token,
     }),
+
+  removeMember: (token: string, projectId: string, userId: string) =>
+    apiFetch(`/api/projects/${projectId}/members/${userId}`, { method: 'DELETE', token }),
+
+  updateMember: (token: string, projectId: string, userId: string, role: string) =>
+    apiFetch(`/api/projects/${projectId}/members/${userId}`, {
+      method: 'PUT',
+      body: JSON.stringify({ role }),
+      token,
+    }),
 };
 
 // ── Assets ───────────────────────────────────────────────────────────────────
@@ -189,13 +199,51 @@ export const usersApi = {
     apiFetch(`/api/users/${id}`, { method: 'DELETE', token }),
 };
 
+// ── Invitations ────────────────────────────────────────────────────────────────
+
+export const invitationsApi = {
+  // Public: verify an invitation token
+  verify: (token: string) =>
+    apiFetch<{ invitation: InvitationInfo }>(`/api/invitations/${token}`),
+
+  // Public: accept invitation (must be logged in with matching email)
+  accept: (token: string) =>
+    apiFetch<{ member: { projectId: string } }>(`/api/invitations/${token}/accept`, {
+      method: 'POST',
+    }),
+
+  // Project-scoped: list pending invitations
+  list: (token: string, projectId: string) =>
+    apiFetch<{ invitations: Invitation[] }>(`/api/invitations/project/${projectId}`, { token }),
+
+  // Project-scoped: create invitation
+  create: (token: string, projectId: string, email: string, role: string) =>
+    apiFetch<{ invitation: Invitation; inviteUrl: string }>(`/api/invitations/project/${projectId}`, {
+      method: 'POST',
+      body: JSON.stringify({ email, role }),
+      token,
+    }),
+
+  // Revoke an invitation
+  revoke: (token: string, invitationId: string) =>
+    apiFetch(`/api/invitations/${invitationId}`, { method: 'DELETE', token }),
+
+  // Resend invitation (new token)
+  resend: (token: string, projectId: string, invitationId: string) =>
+    apiFetch<{ invitation: Invitation; inviteUrl: string }>(`/api/invitations/project/${projectId}/resend`, {
+      method: 'POST',
+      body: JSON.stringify({ invitationId }),
+      token,
+    }),
+};
+
 // ── Types ─────────────────────────────────────────────────────────────────────
 
 export interface User {
   id: string;
   email: string;
   name: string;
-  role: string;
+  globalRole: string;
   avatarUrl?: string | null;
 }
 
@@ -212,11 +260,36 @@ export interface Project {
   id: string;
   name: string;
   description?: string | null;
+  ownerId: string;
   createdAt: string;
-  members: Array<{ id: string; role: string; user: User }>;
+  members: Array<{ id: string; role: string; joinedAt: string; invitedBy?: string | null; user: User }>;
   _count?: { assets: number };
 }
 
+export interface Invitation {
+  id: string;
+  email: string;
+  projectId: string;
+  role: string;
+  token: string;
+  status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED';
+  invitedBy?: string | null;
+  expiresAt: string;
+  createdAt: string;
+}
+
+export interface InvitationInfo {
+  id: string;
+  email: string;
+  role: string;
+  projectName: string;
+  projectId: string;
+  expiresAt: string;
+  isOwnInvitation: boolean;
+  alreadyMember: boolean;
+  isLoggedIn: boolean;
+}
+
 export interface Asset {
   id: string;
   projectId: string;

+ 16 - 4
src/lib/auth-context.tsx

@@ -3,10 +3,17 @@
 import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
 import { authApi, usersApi, User } from './api';
 
+interface AcceptedProject {
+  projectId: string;
+  projectName: string;
+}
+
 interface AuthContextValue {
   user: User | null;
   token: string | null;
   loading: boolean;
+  acceptedProjects: AcceptedProject[];
+  clearAcceptedProjects: () => void;
   login: (email: string, password: string) => Promise<void>;
   register: (email: string, name: string, password: string) => Promise<void>;
   logout: () => Promise<void>;
@@ -20,8 +27,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
   const [user, setUser] = useState<User | null>(null);
   const [token, setToken] = useState<string | null>(null);
   const [loading, setLoading] = useState(true);
+  const [acceptedProjects, setAcceptedProjects] = useState<AcceptedProject[]>([]);
 
-  // Load from localStorage on mount
   useEffect(() => {
     const savedToken = localStorage.getItem('vidreview_token');
     const savedUser = localStorage.getItem('vidreview_user');
@@ -38,20 +45,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     setLoading(false);
   }, []);
 
+  const clearAcceptedProjects = useCallback(() => setAcceptedProjects([]), []);
+
   const login = useCallback(async (email: string, password: string) => {
-    const { user: u, token: t } = await authApi.login({ email, password });
+    const { user: u, token: t, acceptedProjects: ap } = await authApi.login({ email, password });
     localStorage.setItem('vidreview_token', t);
     localStorage.setItem('vidreview_user', JSON.stringify(u));
     setToken(t);
     setUser(u);
+    setAcceptedProjects(ap ?? []);
   }, []);
 
   const register = useCallback(async (email: string, name: string, password: string) => {
-    const { user: u, token: t } = await authApi.register({ email, name, password });
+    const { user: u, token: t, acceptedProjects: ap } = await authApi.register({ email, name, password });
     localStorage.setItem('vidreview_token', t);
     localStorage.setItem('vidreview_user', JSON.stringify(u));
     setToken(t);
     setUser(u);
+    setAcceptedProjects(ap ?? []);
   }, []);
 
   const logout = useCallback(async () => {
@@ -60,6 +71,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     localStorage.removeItem('vidreview_user');
     setToken(null);
     setUser(null);
+    setAcceptedProjects([]);
   }, []);
 
   const refreshUser = useCallback(async () => {
@@ -81,7 +93,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
   }, []);
 
   return (
-    <AuthContext.Provider value={{ user, token, loading, login, register, logout, refreshUser, updateUserData }}>
+    <AuthContext.Provider value={{ user, token, loading, acceptedProjects, clearAcceptedProjects, login, register, logout, refreshUser, updateUserData }}>
       {children}
     </AuthContext.Provider>
   );