page.tsx 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { useAuth } from '@/lib/auth-context';
  4. import { usersApi, invitationsApi, AdminUser, AdminInvitation } from '@/lib/api';
  5. import { Avatar } from '@/components/ui/avatar';
  6. /**
  7. * Global workspace roles (stored in User.globalRole):
  8. *
  9. * ADMIN — Full system access. Manage all users, all projects, quotas, settings.
  10. * Sees every project regardless of membership.
  11. *
  12. * MEMBER — Regular registered user. Can create their own projects,
  13. * invite members, upload videos. Storage quota applies to owned projects.
  14. *
  15. * PROJECT_USER — Invited-only user. Has no workspace presence beyond their invitations.
  16. * Cannot create projects. No storage quota (no owned projects).
  17. *
  18. * (Project-level roles in ProjectMember.role:
  19. * ADMIN | EDITOR | REVIEWER | VIEWER — scoped to a specific project.)
  20. */
  21. async function safeCopy(text: string): Promise<void> {
  22. if (typeof window === 'undefined') return;
  23. try {
  24. const cb = navigator.clipboard;
  25. if (cb && typeof cb.writeText === 'function') {
  26. await cb.writeText(text);
  27. } else {
  28. const el = document.createElement('textarea');
  29. el.value = text;
  30. el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
  31. document.body.appendChild(el);
  32. el.focus(); el.select();
  33. try { document.execCommand('copy'); } catch { /* ignore */ }
  34. document.body.removeChild(el);
  35. }
  36. } catch { /* ignore */ }
  37. }
  38. const GLOBAL_ROLE_CONFIG: Record<string, { label: string; badge: string }> = {
  39. ADMIN: { label: 'Admin', badge: 'badge-danger' },
  40. MEMBER: { label: 'Member', badge: 'badge-muted' },
  41. PROJECT_USER: { label: 'Project User', badge: 'badge-subtle' },
  42. };
  43. export default function UsersPage() {
  44. const { user: currentUser, token } = useAuth();
  45. const [users, setUsers] = useState<AdminUser[]>([]);
  46. const [invitations, setInvitations] = useState<AdminInvitation[]>([]);
  47. const [loading, setLoading] = useState(true);
  48. const [updating, setUpdating] = useState<string | null>(null);
  49. const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
  50. // Quota edit
  51. const [editingQuota, setEditingQuota] = useState<string | null>(null);
  52. const [quotaInput, setQuotaInput] = useState('');
  53. const [quotaUnit, setQuotaUnit] = useState<'MB' | 'GB'>('MB');
  54. const [quotaError, setQuotaError] = useState('');
  55. const formatBytes = (bytes: number): string => {
  56. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  57. if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
  58. return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
  59. };
  60. const parseBytes = (value: string, unit: 'MB' | 'GB'): number => {
  61. const v = parseFloat(value);
  62. if (unit === 'GB') return Math.round(v * 1024 * 1024 * 1024);
  63. return Math.round(v * 1024 * 1024);
  64. };
  65. const openQuotaEdit = (u: AdminUser) => {
  66. const quotaBytes = u.storageQuota;
  67. // Determine best unit: show GB if >= 1024 MB, otherwise MB
  68. const quotaMB = quotaBytes / 1024 / 1024;
  69. if (quotaMB >= 1024) {
  70. // Show in GB
  71. setQuotaInput(String(Math.round(quotaMB / 1024)));
  72. setQuotaUnit('GB');
  73. } else {
  74. setQuotaInput(String(Math.round(quotaMB)));
  75. setQuotaUnit('MB');
  76. }
  77. setQuotaError('');
  78. setEditingQuota(u.id);
  79. };
  80. const handleQuotaSave = async (userId: string) => {
  81. const parsed = parseFloat(quotaInput);
  82. if (isNaN(parsed) || parsed < 1) {
  83. setQuotaError('Enter a value ≥ 1');
  84. return;
  85. }
  86. setUpdating(userId);
  87. setQuotaError('');
  88. try {
  89. const bytes = parseBytes(quotaInput, quotaUnit);
  90. const { user: updated } = await usersApi.updateQuota(token!, userId, bytes);
  91. setUsers(prev => prev.map(u => u.id === userId
  92. ? { ...u, storageQuota: updated.storageQuota, storageUsed: updated.storageUsed }
  93. : u));
  94. setEditingQuota(null);
  95. } catch (err) {
  96. setQuotaError(err instanceof Error ? err.message : 'Failed to update quota');
  97. } finally {
  98. setUpdating(null);
  99. }
  100. };
  101. const [activeTab, setActiveTab] = useState<'users' | 'invites'>('users');
  102. // Invite form — workspace MEMBER invite (email only)
  103. const [inviteEmail, setInviteEmail] = useState('');
  104. const [inviting, setInviting] = useState(false);
  105. const [inviteError, setInviteError] = useState('');
  106. const [inviteSuccess, setInviteSuccess] = useState('');
  107. const [createdLink, setCreatedLink] = useState('');
  108. const [revokingId, setRevokingId] = useState<string | null>(null);
  109. const [copiedId, setCopiedId] = useState<string | null>(null);
  110. const inviteUrlMap = useRef<Record<string, string>>({});
  111. const isAdmin = currentUser?.globalRole === 'ADMIN';
  112. const loadUsers = useCallback(async () => {
  113. if (!token || !isAdmin) return;
  114. try {
  115. const { users: u } = await usersApi.list(token);
  116. setUsers(u);
  117. } catch {
  118. console.error('Failed to load users');
  119. } finally {
  120. setLoading(false);
  121. }
  122. }, [token, isAdmin]);
  123. const loadInvitations = useCallback(async () => {
  124. if (!token || !isAdmin) return;
  125. try {
  126. const { invitations: inv } = await invitationsApi.listAll(token);
  127. setInvitations(inv);
  128. // Cache invite URLs
  129. for (const i of inv) {
  130. inviteUrlMap.current[i.token] = `${window.location.origin}/invite/${i.token}`;
  131. }
  132. } catch {
  133. console.error('Failed to load invitations');
  134. } finally {
  135. setLoading(false);
  136. }
  137. }, [token, isAdmin]);
  138. useEffect(() => {
  139. if (!token || !isAdmin) return;
  140. if (activeTab === 'users') loadUsers();
  141. else loadInvitations();
  142. }, [token, isAdmin, activeTab, loadUsers, loadInvitations]);
  143. // ── Send workspace invite ──────────────────────────────────────────────────────
  144. const handleInvite = async (e: React.FormEvent) => {
  145. e.preventDefault();
  146. if (!token || !inviteEmail.trim()) return;
  147. setInviting(true);
  148. setInviteError('');
  149. setInviteSuccess('');
  150. setCreatedLink('');
  151. try {
  152. // Workspace invite → POST /api/invitations/workspace → creates MEMBER user
  153. const { inviteUrl } = await invitationsApi.inviteMember(token, inviteEmail.trim());
  154. const fullUrl = inviteUrl;
  155. const tokenPart = inviteUrl.split('/invite/').pop()!;
  156. inviteUrlMap.current[tokenPart] = fullUrl;
  157. // Optimistically add to list — backend returns the real invitation
  158. setInvitations(prev => [...prev, {
  159. id: Math.random().toString(),
  160. email: inviteEmail.trim(),
  161. projectId: '',
  162. role: 'MEMBER',
  163. token: tokenPart,
  164. status: 'PENDING',
  165. invitedBy: null,
  166. expiresAt: '',
  167. createdAt: new Date().toISOString(),
  168. project: { id: '', name: 'Workspace' },
  169. type: 'WORKSPACE',
  170. }]);
  171. setInviteEmail('');
  172. setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
  173. await safeCopy(fullUrl);
  174. setCreatedLink(fullUrl);
  175. setTimeout(() => setInviteSuccess(''), 6000);
  176. } catch (err) {
  177. setInviteError(err instanceof Error ? err.message : 'Failed to send invitation');
  178. } finally {
  179. setInviting(false);
  180. }
  181. };
  182. // ── Revoke invite ────────────────────────────────────────────────────────────
  183. const handleRevoke = async (invitationId: string) => {
  184. if (!token) return;
  185. setRevokingId(invitationId);
  186. try {
  187. await invitationsApi.revoke(token, invitationId);
  188. setInvitations(prev => prev.filter(i => i.id !== invitationId));
  189. } catch {
  190. alert('Failed to revoke invitation');
  191. } finally {
  192. setRevokingId(null);
  193. }
  194. };
  195. // ── Copy link ────────────────────────────────────────────────────────────────
  196. const handleCopy = async (inv: AdminInvitation) => {
  197. // Build full URL: either from cache or construct from known origin
  198. const url = inviteUrlMap.current[inv.token] ?? `${window.location.origin}/invite/${inv.token}`;
  199. await navigator.clipboard.writeText(url).catch(() => {});
  200. setCopiedId(inv.id);
  201. setTimeout(() => setCopiedId(null), 2000);
  202. };
  203. // ── User actions ─────────────────────────────────────────────────────────────
  204. const handleGlobalRoleChange = async (userId: string, globalRole: string) => {
  205. if (!token) return;
  206. setUpdating(userId);
  207. try {
  208. const { user: updated } = await usersApi.updateRole(token, userId, globalRole);
  209. setUsers(prev => prev.map(u => u.id === userId
  210. ? { ...u, globalRole: (updated as any).globalRole ?? globalRole }
  211. : u));
  212. } catch (err) {
  213. alert(err instanceof Error ? err.message : 'Failed to update role');
  214. } finally {
  215. setUpdating(null);
  216. }
  217. };
  218. const handleToggleActive = async (userId: string, currentActive: boolean) => {
  219. if (!token) return;
  220. setUpdating(userId);
  221. try {
  222. const { user: updated } = await usersApi.updateActive(token, userId, !currentActive);
  223. setUsers(prev => prev.map(u => u.id === userId ? { ...u, active: updated.active } : u));
  224. } catch (err) {
  225. alert(err instanceof Error ? err.message : 'Failed to update status');
  226. } finally {
  227. setUpdating(null);
  228. }
  229. };
  230. const handleDelete = async (userId: string) => {
  231. if (!token) return;
  232. try {
  233. await usersApi.deleteUser(token, userId);
  234. setUsers(prev => prev.filter(u => u.id !== userId));
  235. setConfirmDelete(null);
  236. } catch (err) {
  237. alert(err instanceof Error ? err.message : 'Failed to delete user');
  238. }
  239. };
  240. if (!isAdmin) {
  241. return (
  242. <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
  243. <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Admin access required</p>
  244. </div>
  245. );
  246. }
  247. return (
  248. <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
  249. {/* Header */}
  250. <header className="sticky top-0 z-10 px-4 md:px-8 py-4 flex items-center justify-between shrink-0 gap-3"
  251. style={{
  252. background: 'rgba(10,11,20,0.80)',
  253. backdropFilter: 'blur(12px)',
  254. borderBottom: '1px solid rgba(255,255,255,0.06)',
  255. }}>
  256. <div>
  257. <h1 className="text-xl font-semibold" style={{ color: 'var(--text)' }}>Workspace</h1>
  258. <p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
  259. {loading ? '…' : activeTab === 'users' ? `${users.length} user${users.length !== 1 ? 's' : ''}` : `${invitations.length} pending invitation${invitations.length !== 1 ? 's' : ''}`}
  260. </p>
  261. </div>
  262. {/* Tabs */}
  263. <div className="flex items-center gap-1 p-1 rounded-lg"
  264. style={{ background: 'rgba(255,255,255,0.04)' }}>
  265. {[['users', 'Users'], ['invites', 'Invitations']].map(([tab, label]) => (
  266. <button key={tab}
  267. onClick={() => setActiveTab(tab as any)}
  268. className="px-4 py-1.5 rounded-md text-xs font-medium transition-all"
  269. style={{
  270. background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
  271. color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
  272. }}>
  273. {label}
  274. {tab === 'invites' && invitations.length > 0 && (
  275. <span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded-full"
  276. style={{ background: 'rgba(255,255,255,0.06)', color: 'inherit' }}>
  277. {invitations.length}
  278. </span>
  279. )}
  280. </button>
  281. ))}
  282. </div>
  283. <button onClick={() => activeTab === 'users' ? loadUsers() : loadInvitations()} className="btn btn-secondary btn-md">
  284. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  285. <path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
  286. </svg>
  287. <span className="hidden sm:inline">Refresh</span>
  288. </button>
  289. </header>
  290. <div className="px-4 md:px-8 py-6">
  291. {/* ── Users Tab ──────────────────────────────────────────────────────── */}
  292. {activeTab === 'users' && (
  293. <>
  294. {loading ? (
  295. <div className="space-y-3">
  296. {[1,2,3,4].map(i => (
  297. <div key={i} className="card h-20 skeleton" style={{ animationDelay: `${i*60}ms` }} />
  298. ))}
  299. </div>
  300. ) : (
  301. <div className="space-y-3">
  302. {users.map(u => {
  303. const roleCfg = GLOBAL_ROLE_CONFIG[u.globalRole] ?? GLOBAL_ROLE_CONFIG.MEMBER;
  304. const isMe = u.id === currentUser?.id;
  305. return (
  306. <div key={u.id}
  307. className="card animate-fade-in overflow-hidden"
  308. style={{ opacity: u.active ? 1 : 0.5 }}>
  309. {/* Top row: avatar + info + role/actions */}
  310. <div className="flex items-start gap-3 p-4">
  311. {/* Avatar */}
  312. <Avatar name={u.name} src={u.avatarUrl} size="md" />
  313. {/* Info */}
  314. <div className="flex-1 min-w-0">
  315. <div className="flex flex-wrap items-center gap-1.5">
  316. <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{u.name}</span>
  317. {isMe && <span className="badge badge-brand text-[10px]">you</span>}
  318. {!u.active && <span className="badge badge-muted text-[10px]">inactive</span>}
  319. </div>
  320. <p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-muted)' }}>{u.email}</p>
  321. </div>
  322. {/* Role + Actions */}
  323. <div className="flex items-center gap-1.5 shrink-0">
  324. <select
  325. value={u.globalRole}
  326. onChange={e => handleGlobalRoleChange(u.id, e.target.value)}
  327. disabled={updating === u.id || isMe}
  328. className="input text-xs py-1.5"
  329. style={{ minWidth: '90px', maxWidth: '100px' }}
  330. >
  331. {Object.entries(GLOBAL_ROLE_CONFIG).map(([value, cfg]) => (
  332. <option key={value} value={value}>{cfg.label}</option>
  333. ))}
  334. </select>
  335. {!isMe && (
  336. <div className="flex items-center gap-1">
  337. <button
  338. onClick={() => openQuotaEdit(u)}
  339. disabled={updating === u.id}
  340. className="btn btn-secondary btn-sm"
  341. title="Edit storage quota"
  342. >
  343. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  344. <path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
  345. </svg>
  346. </button>
  347. <button
  348. onClick={() => handleToggleActive(u.id, u.active)}
  349. disabled={updating === u.id}
  350. className={`btn btn-sm ${u.active ? 'btn-secondary' : 'btn-primary'}`}
  351. title={u.active ? 'Deactivate' : 'Activate'}
  352. >
  353. {u.active ? (
  354. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  355. <path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
  356. </svg>
  357. ) : (
  358. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  359. <path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
  360. </svg>
  361. )}
  362. </button>
  363. <button
  364. onClick={() => setConfirmDelete(u.id)}
  365. className="btn btn-danger btn-sm"
  366. title="Delete user"
  367. >
  368. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  369. <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
  370. </svg>
  371. </button>
  372. </div>
  373. )}
  374. </div>
  375. </div>
  376. {/* Stats row — desktop only */}
  377. <div className="hidden sm:flex items-center gap-4 px-4 pb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
  378. <span>{u.ownedProjects ?? 0} owned</span>
  379. <span>{((u._count?.memberships ?? 0) - (u.ownedProjects ?? 0))} shared</span>
  380. <span>{u._count?.comments ?? 0} comments</span>
  381. <span>{new Date(u.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</span>
  382. {u.ownedProjects > 0 && (
  383. <div className="flex items-center gap-2">
  384. <div className="relative w-16 h-1.5 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
  385. <div
  386. className="absolute left-0 top-0 h-full rounded-full transition-all"
  387. style={{
  388. width: `${Math.min(100, Math.round((u.storageUsed / Math.max(u.storageQuota, 1)) * 100))}%`,
  389. background: u.storageUsed >= u.storageQuota ? '#F87171' : '#6366F1',
  390. }}
  391. />
  392. </div>
  393. <span className="whitespace-nowrap" style={{ color: u.storageUsed >= u.storageQuota ? '#F87171' : undefined }}>
  394. {formatBytes(u.storageUsed)} / {formatBytes(u.storageQuota)}
  395. </span>
  396. </div>
  397. )}
  398. {u.ownedProjects === 0 && (
  399. <span style={{ color: 'var(--text-subtle)' }}>—</span>
  400. )}
  401. </div>
  402. {/* Mobile stats — shown only on small screens */}
  403. <div className="flex sm:hidden items-center gap-3 px-4 pb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
  404. <span>{u.ownedProjects ?? 0} owned</span>
  405. <span>{u._count?.comments ?? 0} comments</span>
  406. </div>
  407. {/* Inline quota editor */}
  408. {editingQuota === u.id && (
  409. <div className="mx-4 mb-4 p-3 rounded-lg animate-fade-in"
  410. style={{
  411. background: 'rgba(99,102,241,0.06)',
  412. border: '1px solid rgba(99,102,241,0.20)',
  413. }}>
  414. <div className="flex items-center gap-2 flex-wrap">
  415. <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#A5B4FC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  416. <path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
  417. </svg>
  418. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
  419. Quota for {u.name}:
  420. </span>
  421. <div className="flex items-center gap-1">
  422. <input
  423. type="number"
  424. min="1"
  425. step="1"
  426. className="input text-xs py-1 w-20"
  427. value={quotaInput}
  428. onChange={e => setQuotaInput(e.target.value)}
  429. onKeyDown={e => e.key === 'Enter' && handleQuotaSave(u.id)}
  430. autoFocus
  431. />
  432. <select
  433. className="input text-xs py-1"
  434. value={quotaUnit}
  435. onChange={e => setQuotaUnit(e.target.value as 'MB' | 'GB')}
  436. >
  437. <option value="MB">MB</option>
  438. <option value="GB">GB</option>
  439. </select>
  440. </div>
  441. <button
  442. onClick={() => handleQuotaSave(u.id)}
  443. disabled={updating === u.id}
  444. className="btn btn-primary btn-sm text-xs"
  445. >
  446. Save
  447. </button>
  448. <button
  449. onClick={() => { setEditingQuota(null); setQuotaError(''); }}
  450. className="btn btn-secondary btn-sm text-xs"
  451. >
  452. Cancel
  453. </button>
  454. {quotaError && (
  455. <span className="text-xs w-full" style={{ color: '#F87171' }}>{quotaError}</span>
  456. )}
  457. </div>
  458. <div className="mt-2">
  459. <div className="relative w-full h-1.5 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
  460. <div
  461. className="absolute left-0 top-0 h-full rounded-full"
  462. style={{
  463. width: `${Math.min(100, Math.round((u.storageUsed / Math.max(parseBytes(quotaInput, quotaUnit), 1)) * 100))}%`,
  464. background: u.storageUsed >= parseBytes(quotaInput, quotaUnit) ? '#F87171' : '#6366F1',
  465. }}
  466. />
  467. </div>
  468. <p className="text-[10px] mt-1" style={{ color: 'var(--text-subtle)' }}>
  469. Currently used: {formatBytes(u.storageUsed)} ({Math.round((u.storageUsed / Math.max(u.storageQuota, 1)) * 100)}%)
  470. </p>
  471. </div>
  472. </div>
  473. )}
  474. </div>
  475. );
  476. })}
  477. </div>
  478. )}
  479. </>
  480. )}
  481. {/* ── Invitations Tab ────────────────────────────────────────────────── */}
  482. {activeTab === 'invites' && (
  483. <div className="max-w-3xl animate-fade-in">
  484. {/* Invite form */}
  485. <div className="card p-5 mb-6">
  486. <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
  487. Invite member
  488. </h2>
  489. <form onSubmit={handleInvite} className="space-y-4">
  490. <div className="flex flex-col sm:flex-row items-stretch sm:items-end gap-3">
  491. <div className="flex-1">
  492. <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>
  493. Invite email address
  494. </label>
  495. <input
  496. type="email"
  497. className="input"
  498. value={inviteEmail}
  499. onChange={e => setInviteEmail(e.target.value)}
  500. placeholder="colleague@company.com"
  501. required
  502. />
  503. </div>
  504. <button type="submit" disabled={inviting || !inviteEmail.trim()} className="btn btn-primary btn-md shrink-0">
  505. {inviting ? 'Sending…' : (
  506. <span className="flex items-center gap-1.5">
  507. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  508. <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" />
  509. </svg>
  510. Send Invite
  511. </span>
  512. )}
  513. </button>
  514. </div>
  515. {inviteError && (
  516. <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
  517. )}
  518. {inviteSuccess && (
  519. <span className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</span>
  520. )}
  521. {createdLink && (
  522. <div className="rounded-lg p-3 animate-scale-in"
  523. style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
  524. <div className="flex items-center gap-2 mb-1">
  525. <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  526. <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  527. </svg>
  528. <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied to clipboard!</span>
  529. </div>
  530. <p className="text-[10px] truncate" style={{ color: 'rgba(134,239,172,0.7)' }}>{createdLink}</p>
  531. </div>
  532. )}
  533. </form>
  534. </div>
  535. {/* Pending invitations */}
  536. <div className="card overflow-hidden">
  537. <div className="px-5 py-4 border-b flex items-center justify-between"
  538. style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
  539. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Pending invitations</h2>
  540. <span className="text-xs px-2 py-0.5 rounded-full"
  541. style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
  542. {invitations.length}
  543. </span>
  544. </div>
  545. {loading ? (
  546. <div className="p-8 text-center">
  547. <div className="w-5 h-5 rounded-full animate-spin mx-auto"
  548. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  549. </div>
  550. ) : invitations.length === 0 ? (
  551. <div className="p-8 text-center">
  552. <div className="w-12 h-12 rounded-2xl mx-auto mb-3 flex items-center justify-center"
  553. style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
  554. <svg className="w-5 h-5" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  555. <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" />
  556. </svg>
  557. </div>
  558. <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No pending invitations</p>
  559. <p className="text-xs mt-1" style={{ color: 'var(--text-subtle)' }}>Use the form above to invite new members</p>
  560. </div>
  561. ) : (
  562. <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  563. {invitations.map(inv => (
  564. <div key={inv.id} className="flex items-center gap-4 px-5 py-4">
  565. {/* Icon */}
  566. <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
  567. style={{ background: 'rgba(99,102,241,0.08)' }}>
  568. <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  569. <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" />
  570. </svg>
  571. </div>
  572. {/* Info */}
  573. <div className="flex-1 min-w-0">
  574. <div className="flex items-center gap-2">
  575. <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
  576. {inv.type === 'WORKSPACE' ? (
  577. <span className="badge badge-danger text-[10px]">Workspace</span>
  578. ) : (
  579. <span className="badge badge-brand text-[10px]">Project</span>
  580. )}
  581. </div>
  582. <div className="flex items-center gap-2 mt-0.5">
  583. <span className="text-xs" style={{ color: '#818CF8' }}>
  584. {inv.type === 'WORKSPACE' ? 'Workspace member' : (inv.project?.name ?? '—')}
  585. </span>
  586. <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>·</span>
  587. <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
  588. Sent {new Date(inv.createdAt).toLocaleDateString()} · Expires {new Date(inv.expiresAt).toLocaleDateString()}
  589. </span>
  590. </div>
  591. </div>
  592. {/* Actions */}
  593. <div className="flex items-center gap-1.5 shrink-0">
  594. <button
  595. onClick={() => handleCopy(inv)}
  596. className="btn btn-secondary btn-sm"
  597. title="Copy invite link"
  598. >
  599. {copiedId === inv.id ? (
  600. <svg className="w-3.5 h-3.5" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  601. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  602. </svg>
  603. ) : (
  604. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  605. <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" />
  606. </svg>
  607. )}
  608. </button>
  609. <button
  610. onClick={() => handleRevoke(inv.id)}
  611. disabled={revokingId === inv.id}
  612. className="btn btn-danger btn-sm"
  613. title="Revoke invitation"
  614. >
  615. {revokingId === inv.id ? '…' : (
  616. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  617. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  618. </svg>
  619. )}
  620. </button>
  621. </div>
  622. </div>
  623. ))}
  624. </div>
  625. )}
  626. {invitations.length > 0 && (
  627. <div className="px-5 py-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  628. <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>
  629. Invitation links expire after 7 days. Copy a link and send it manually to your invitee.
  630. </p>
  631. </div>
  632. )}
  633. </div>
  634. </div>
  635. )}
  636. </div>
  637. {/* Delete confirm modal */}
  638. {confirmDelete && (
  639. <div className="fixed inset-0 z-50 flex items-center justify-center"
  640. style={{ background: 'rgba(0,0,0,0.7)' }}>
  641. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in"
  642. style={{ maxWidth: '400px' }}>
  643. <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text)' }}>
  644. Delete user?
  645. </h3>
  646. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  647. This will permanently delete the user and all their comments. This action cannot be undone.
  648. </p>
  649. <div className="flex gap-3 justify-end">
  650. <button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-md">
  651. Cancel
  652. </button>
  653. <button
  654. onClick={() => handleDelete(confirmDelete)}
  655. className="btn btn-danger btn-md"
  656. >
  657. Delete
  658. </button>
  659. </div>
  660. </div>
  661. </div>
  662. )}
  663. </div>
  664. );
  665. }