'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { useAuth } from '@/lib/auth-context'; import { usersApi, invitationsApi, AdminUser, AdminInvitation } from '@/lib/api'; import { Avatar } from '@/components/ui/avatar'; /** * Global workspace roles (stored in User.globalRole): * * ADMIN — Full system access. Manage all users, all projects, quotas, settings. * Sees every project regardless of membership. * * MEMBER — Regular registered user. Can create their own projects, * invite members, upload videos. Storage quota applies to owned projects. * * PROJECT_USER — Invited-only user. Has no workspace presence beyond their invitations. * Cannot create projects. No storage quota (no owned projects). * * (Project-level roles in ProjectMember.role: * ADMIN | EDITOR | REVIEWER | VIEWER — scoped to a specific project.) */ async function safeCopy(text: string): Promise { if (typeof window === 'undefined') return; try { const cb = navigator.clipboard; if (cb && typeof cb.writeText === 'function') { await cb.writeText(text); } else { const el = document.createElement('textarea'); el.value = text; el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0'; document.body.appendChild(el); el.focus(); el.select(); try { document.execCommand('copy'); } catch { /* ignore */ } document.body.removeChild(el); } } catch { /* ignore */ } } const GLOBAL_ROLE_CONFIG: Record = { ADMIN: { label: 'Admin', badge: 'badge-danger' }, MEMBER: { label: 'Member', badge: 'badge-muted' }, PROJECT_USER: { label: 'Project User', badge: 'badge-subtle' }, }; export default function UsersPage() { const { user: currentUser, token } = useAuth(); const [users, setUsers] = useState([]); const [invitations, setInvitations] = useState([]); const [loading, setLoading] = useState(true); const [updating, setUpdating] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); // Quota edit const [editingQuota, setEditingQuota] = useState(null); const [quotaInput, setQuotaInput] = useState(''); const [quotaUnit, setQuotaUnit] = useState<'MB' | 'GB'>('MB'); const [quotaError, setQuotaError] = useState(''); const formatBytes = (bytes: number): string => { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; }; const parseBytes = (value: string, unit: 'MB' | 'GB'): number => { const v = parseFloat(value); if (unit === 'GB') return Math.round(v * 1024 * 1024 * 1024); return Math.round(v * 1024 * 1024); }; const openQuotaEdit = (u: AdminUser) => { const quotaBytes = u.storageQuota; // Determine best unit: show GB if >= 1024 MB, otherwise MB const quotaMB = quotaBytes / 1024 / 1024; if (quotaMB >= 1024) { // Show in GB setQuotaInput(String(Math.round(quotaMB / 1024))); setQuotaUnit('GB'); } else { setQuotaInput(String(Math.round(quotaMB))); setQuotaUnit('MB'); } setQuotaError(''); setEditingQuota(u.id); }; const handleQuotaSave = async (userId: string) => { const parsed = parseFloat(quotaInput); if (isNaN(parsed) || parsed < 1) { setQuotaError('Enter a value ≥ 1'); return; } setUpdating(userId); setQuotaError(''); try { const bytes = parseBytes(quotaInput, quotaUnit); const { user: updated } = await usersApi.updateQuota(token!, userId, bytes); setUsers(prev => prev.map(u => u.id === userId ? { ...u, storageQuota: updated.storageQuota, storageUsed: updated.storageUsed } : u)); setEditingQuota(null); } catch (err) { setQuotaError(err instanceof Error ? err.message : 'Failed to update quota'); } finally { setUpdating(null); } }; const [activeTab, setActiveTab] = useState<'users' | 'invites'>('users'); // Invite form — workspace MEMBER invite (email only) const [inviteEmail, setInviteEmail] = useState(''); const [inviting, setInviting] = useState(false); const [inviteError, setInviteError] = useState(''); const [inviteSuccess, setInviteSuccess] = useState(''); const [createdLink, setCreatedLink] = useState(''); const [revokingId, setRevokingId] = useState(null); const [copiedId, setCopiedId] = useState(null); const inviteUrlMap = useRef>({}); const isAdmin = currentUser?.globalRole === 'ADMIN'; const loadUsers = useCallback(async () => { if (!token || !isAdmin) return; try { const { users: u } = await usersApi.list(token); setUsers(u); } catch { console.error('Failed to load users'); } finally { setLoading(false); } }, [token, isAdmin]); const loadInvitations = useCallback(async () => { if (!token || !isAdmin) return; try { const { invitations: inv } = await invitationsApi.listAll(token); setInvitations(inv); // Cache invite URLs for (const i of inv) { inviteUrlMap.current[i.token] = `${window.location.origin}/invite/${i.token}`; } } catch { console.error('Failed to load invitations'); } finally { setLoading(false); } }, [token, isAdmin]); useEffect(() => { if (!token || !isAdmin) return; if (activeTab === 'users') loadUsers(); else loadInvitations(); }, [token, isAdmin, activeTab, loadUsers, loadInvitations]); // ── Send workspace invite ────────────────────────────────────────────────────── const handleInvite = async (e: React.FormEvent) => { e.preventDefault(); if (!token || !inviteEmail.trim()) return; setInviting(true); setInviteError(''); setInviteSuccess(''); setCreatedLink(''); try { // Workspace invite → POST /api/invitations/workspace → creates MEMBER user const { inviteUrl } = await invitationsApi.inviteMember(token, inviteEmail.trim()); const fullUrl = inviteUrl; const tokenPart = inviteUrl.split('/invite/').pop()!; inviteUrlMap.current[tokenPart] = fullUrl; // Optimistically add to list — backend returns the real invitation setInvitations(prev => [...prev, { id: Math.random().toString(), email: inviteEmail.trim(), projectId: '', role: 'MEMBER', token: tokenPart, status: 'PENDING', invitedBy: null, expiresAt: '', createdAt: new Date().toISOString(), project: { id: '', name: 'Workspace' }, type: 'WORKSPACE', }]); setInviteEmail(''); setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`); await safeCopy(fullUrl); setCreatedLink(fullUrl); setTimeout(() => setInviteSuccess(''), 6000); } catch (err) { setInviteError(err instanceof Error ? err.message : 'Failed to send invitation'); } finally { setInviting(false); } }; // ── Revoke invite ──────────────────────────────────────────────────────────── const handleRevoke = async (invitationId: string) => { if (!token) return; setRevokingId(invitationId); try { await invitationsApi.revoke(token, invitationId); setInvitations(prev => prev.filter(i => i.id !== invitationId)); } catch { alert('Failed to revoke invitation'); } finally { setRevokingId(null); } }; // ── Copy link ──────────────────────────────────────────────────────────────── const handleCopy = async (inv: AdminInvitation) => { // Build full URL: either from cache or construct from known origin const url = inviteUrlMap.current[inv.token] ?? `${window.location.origin}/invite/${inv.token}`; await navigator.clipboard.writeText(url).catch(() => {}); setCopiedId(inv.id); setTimeout(() => setCopiedId(null), 2000); }; // ── User actions ───────────────────────────────────────────────────────────── const handleGlobalRoleChange = async (userId: string, globalRole: string) => { if (!token) return; setUpdating(userId); try { 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 { setUpdating(null); } }; const handleToggleActive = async (userId: string, currentActive: boolean) => { if (!token) return; setUpdating(userId); try { const { user: updated } = await usersApi.updateActive(token, userId, !currentActive); setUsers(prev => prev.map(u => u.id === userId ? { ...u, active: updated.active } : u)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to update status'); } finally { setUpdating(null); } }; const handleDelete = async (userId: string) => { if (!token) return; try { await usersApi.deleteUser(token, userId); setUsers(prev => prev.filter(u => u.id !== userId)); setConfirmDelete(null); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to delete user'); } }; if (!isAdmin) { return (

Admin access required

); } return (
{/* Header */}

Workspace

{loading ? '…' : activeTab === 'users' ? `${users.length} user${users.length !== 1 ? 's' : ''}` : `${invitations.length} pending invitation${invitations.length !== 1 ? 's' : ''}`}

{/* Tabs */}
{[['users', 'Users'], ['invites', 'Invitations']].map(([tab, label]) => ( ))}
{/* ── Users Tab ──────────────────────────────────────────────────────── */} {activeTab === 'users' && ( <> {loading ? (
{[1,2,3,4].map(i => (
))}
) : (
{users.map(u => { const roleCfg = GLOBAL_ROLE_CONFIG[u.globalRole] ?? GLOBAL_ROLE_CONFIG.MEMBER; const isMe = u.id === currentUser?.id; return (
{/* Top row: avatar + info + role/actions */}
{/* Avatar */} {/* Info */}
{u.name} {isMe && you} {!u.active && inactive}

{u.email}

{/* Role + Actions */}
{!isMe && (
)}
{/* Stats row — desktop only */}
{u.ownedProjects ?? 0} owned {((u._count?.memberships ?? 0) - (u.ownedProjects ?? 0))} shared {u._count?.comments ?? 0} comments {new Date(u.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} {u.ownedProjects > 0 && (
= u.storageQuota ? '#F87171' : '#6366F1', }} />
= u.storageQuota ? '#F87171' : undefined }}> {formatBytes(u.storageUsed)} / {formatBytes(u.storageQuota)}
)} {u.ownedProjects === 0 && ( )}
{/* Mobile stats — shown only on small screens */}
{u.ownedProjects ?? 0} owned {u._count?.comments ?? 0} comments
{/* Inline quota editor */} {editingQuota === u.id && (
Quota for {u.name}:
setQuotaInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleQuotaSave(u.id)} autoFocus />
{quotaError && ( {quotaError} )}
= parseBytes(quotaInput, quotaUnit) ? '#F87171' : '#6366F1', }} />

Currently used: {formatBytes(u.storageUsed)} ({Math.round((u.storageUsed / Math.max(u.storageQuota, 1)) * 100)}%)

)}
); })}
)} )} {/* ── Invitations Tab ────────────────────────────────────────────────── */} {activeTab === 'invites' && (
{/* Invite form */}

Invite member

setInviteEmail(e.target.value)} placeholder="colleague@company.com" required />
{inviteError && (

{inviteError}

)} {inviteSuccess && ( {inviteSuccess} )} {createdLink && (
Link copied to clipboard!

{createdLink}

)}
{/* Pending invitations */}

Pending invitations

{invitations.length}
{loading ? (
) : invitations.length === 0 ? (

No pending invitations

Use the form above to invite new members

) : (
{invitations.map(inv => (
{/* Icon */}
{/* Info */}
{inv.email} {inv.type === 'WORKSPACE' ? ( Workspace ) : ( Project )}
{inv.type === 'WORKSPACE' ? 'Workspace member' : (inv.project?.name ?? '—')} · Sent {new Date(inv.createdAt).toLocaleDateString()} · Expires {new Date(inv.expiresAt).toLocaleDateString()}
{/* Actions */}
))}
)} {invitations.length > 0 && (

Invitation links expire after 7 days. Copy a link and send it manually to your invitee.

)}
)}
{/* Delete confirm modal */} {confirmDelete && (

Delete user?

This will permanently delete the user and all their comments. This action cannot be undone.

)}
); }