| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706 |
- '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<void> {
- 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<string, { label: string; badge: string }> = {
- 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<AdminUser[]>([]);
- const [invitations, setInvitations] = useState<AdminInvitation[]>([]);
- const [loading, setLoading] = useState(true);
- const [updating, setUpdating] = useState<string | null>(null);
- const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
- // Quota edit
- const [editingQuota, setEditingQuota] = useState<string | null>(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<string | null>(null);
- const [copiedId, setCopiedId] = useState<string | null>(null);
- const inviteUrlMap = useRef<Record<string, string>>({});
- 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 (
- <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>
- </div>
- );
- }
- return (
- <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
- {/* Header */}
- <header className="sticky top-0 z-10 px-4 md:px-8 py-4 flex items-center justify-between shrink-0 gap-3"
- style={{
- background: 'rgba(10,11,20,0.80)',
- backdropFilter: 'blur(12px)',
- borderBottom: '1px solid rgba(255,255,255,0.06)',
- }}>
- <div>
- <h1 className="text-xl font-semibold" style={{ color: 'var(--text)' }}>Workspace</h1>
- <p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
- {loading ? '…' : activeTab === 'users' ? `${users.length} user${users.length !== 1 ? 's' : ''}` : `${invitations.length} pending invitation${invitations.length !== 1 ? 's' : ''}`}
- </p>
- </div>
- {/* Tabs */}
- <div className="flex items-center gap-1 p-1 rounded-lg"
- style={{ background: 'rgba(255,255,255,0.04)' }}>
- {[['users', 'Users'], ['invites', 'Invitations']].map(([tab, label]) => (
- <button key={tab}
- onClick={() => setActiveTab(tab as any)}
- className="px-4 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 === 'invites' && invitations.length > 0 && (
- <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' }}>
- {invitations.length}
- </span>
- )}
- </button>
- ))}
- </div>
- <button onClick={() => activeTab === 'users' ? loadUsers() : loadInvitations()} className="btn btn-secondary btn-md">
- <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.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" />
- </svg>
- <span className="hidden sm:inline">Refresh</span>
- </button>
- </header>
- <div className="px-4 md:px-8 py-6">
- {/* ── Users Tab ──────────────────────────────────────────────────────── */}
- {activeTab === 'users' && (
- <>
- {loading ? (
- <div className="space-y-3">
- {[1,2,3,4].map(i => (
- <div key={i} className="card h-20 skeleton" style={{ animationDelay: `${i*60}ms` }} />
- ))}
- </div>
- ) : (
- <div className="space-y-3">
- {users.map(u => {
- const roleCfg = GLOBAL_ROLE_CONFIG[u.globalRole] ?? GLOBAL_ROLE_CONFIG.MEMBER;
- const isMe = u.id === currentUser?.id;
- return (
- <div key={u.id}
- className="card animate-fade-in overflow-hidden"
- style={{ opacity: u.active ? 1 : 0.5 }}>
- {/* Top row: avatar + info + role/actions */}
- <div className="flex items-start gap-3 p-4">
- {/* Avatar */}
- <Avatar name={u.name} src={u.avatarUrl} size="md" />
- {/* Info */}
- <div className="flex-1 min-w-0">
- <div className="flex flex-wrap items-center gap-1.5">
- <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{u.name}</span>
- {isMe && <span className="badge badge-brand text-[10px]">you</span>}
- {!u.active && <span className="badge badge-muted text-[10px]">inactive</span>}
- </div>
- <p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-muted)' }}>{u.email}</p>
- </div>
- {/* Role + Actions */}
- <div className="flex items-center gap-1.5 shrink-0">
- <select
- value={u.globalRole}
- onChange={e => handleGlobalRoleChange(u.id, e.target.value)}
- disabled={updating === u.id || isMe}
- className="input text-xs py-1.5"
- style={{ minWidth: '90px', maxWidth: '100px' }}
- >
- {Object.entries(GLOBAL_ROLE_CONFIG).map(([value, cfg]) => (
- <option key={value} value={value}>{cfg.label}</option>
- ))}
- </select>
- {!isMe && (
- <div className="flex items-center gap-1">
- <button
- onClick={() => openQuotaEdit(u)}
- disabled={updating === u.id}
- className="btn btn-secondary btn-sm"
- title="Edit storage quota"
- >
- <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="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" />
- </svg>
- </button>
- <button
- onClick={() => handleToggleActive(u.id, u.active)}
- disabled={updating === u.id}
- className={`btn btn-sm ${u.active ? 'btn-secondary' : 'btn-primary'}`}
- title={u.active ? 'Deactivate' : 'Activate'}
- >
- {u.active ? (
- <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="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" />
- </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="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" />
- </svg>
- )}
- </button>
- <button
- onClick={() => setConfirmDelete(u.id)}
- className="btn btn-danger btn-sm"
- title="Delete user"
- >
- <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="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" />
- </svg>
- </button>
- </div>
- )}
- </div>
- </div>
- {/* Stats row — desktop only */}
- <div className="hidden sm:flex items-center gap-4 px-4 pb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
- <span>{u.ownedProjects ?? 0} owned</span>
- <span>{((u._count?.memberships ?? 0) - (u.ownedProjects ?? 0))} shared</span>
- <span>{u._count?.comments ?? 0} comments</span>
- <span>{new Date(u.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</span>
- {u.ownedProjects > 0 && (
- <div className="flex items-center gap-2">
- <div className="relative w-16 h-1.5 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
- <div
- className="absolute left-0 top-0 h-full rounded-full transition-all"
- style={{
- width: `${Math.min(100, Math.round((u.storageUsed / Math.max(u.storageQuota, 1)) * 100))}%`,
- background: u.storageUsed >= u.storageQuota ? '#F87171' : '#6366F1',
- }}
- />
- </div>
- <span className="whitespace-nowrap" style={{ color: u.storageUsed >= u.storageQuota ? '#F87171' : undefined }}>
- {formatBytes(u.storageUsed)} / {formatBytes(u.storageQuota)}
- </span>
- </div>
- )}
- {u.ownedProjects === 0 && (
- <span style={{ color: 'var(--text-subtle)' }}>—</span>
- )}
- </div>
- {/* Mobile stats — shown only on small screens */}
- <div className="flex sm:hidden items-center gap-3 px-4 pb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
- <span>{u.ownedProjects ?? 0} owned</span>
- <span>{u._count?.comments ?? 0} comments</span>
- </div>
- {/* Inline quota editor */}
- {editingQuota === u.id && (
- <div className="mx-4 mb-4 p-3 rounded-lg animate-fade-in"
- style={{
- background: 'rgba(99,102,241,0.06)',
- border: '1px solid rgba(99,102,241,0.20)',
- }}>
- <div className="flex items-center gap-2 flex-wrap">
- <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#A5B4FC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <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" />
- </svg>
- <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
- Quota for {u.name}:
- </span>
- <div className="flex items-center gap-1">
- <input
- type="number"
- min="1"
- step="1"
- className="input text-xs py-1 w-20"
- value={quotaInput}
- onChange={e => setQuotaInput(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && handleQuotaSave(u.id)}
- autoFocus
- />
- <select
- className="input text-xs py-1"
- value={quotaUnit}
- onChange={e => setQuotaUnit(e.target.value as 'MB' | 'GB')}
- >
- <option value="MB">MB</option>
- <option value="GB">GB</option>
- </select>
- </div>
- <button
- onClick={() => handleQuotaSave(u.id)}
- disabled={updating === u.id}
- className="btn btn-primary btn-sm text-xs"
- >
- Save
- </button>
- <button
- onClick={() => { setEditingQuota(null); setQuotaError(''); }}
- className="btn btn-secondary btn-sm text-xs"
- >
- Cancel
- </button>
- {quotaError && (
- <span className="text-xs w-full" style={{ color: '#F87171' }}>{quotaError}</span>
- )}
- </div>
- <div className="mt-2">
- <div className="relative w-full h-1.5 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
- <div
- className="absolute left-0 top-0 h-full rounded-full"
- style={{
- width: `${Math.min(100, Math.round((u.storageUsed / Math.max(parseBytes(quotaInput, quotaUnit), 1)) * 100))}%`,
- background: u.storageUsed >= parseBytes(quotaInput, quotaUnit) ? '#F87171' : '#6366F1',
- }}
- />
- </div>
- <p className="text-[10px] mt-1" style={{ color: 'var(--text-subtle)' }}>
- Currently used: {formatBytes(u.storageUsed)} ({Math.round((u.storageUsed / Math.max(u.storageQuota, 1)) * 100)}%)
- </p>
- </div>
- </div>
- )}
- </div>
- );
- })}
- </div>
- )}
- </>
- )}
- {/* ── Invitations Tab ────────────────────────────────────────────────── */}
- {activeTab === 'invites' && (
- <div className="max-w-3xl animate-fade-in">
- {/* Invite form */}
- <div className="card p-5 mb-6">
- <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
- Invite member
- </h2>
- <form onSubmit={handleInvite} className="space-y-4">
- <div className="flex flex-col sm:flex-row items-stretch sm:items-end gap-3">
- <div className="flex-1">
- <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>
- Invite email address
- </label>
- <input
- type="email"
- className="input"
- value={inviteEmail}
- onChange={e => setInviteEmail(e.target.value)}
- placeholder="colleague@company.com"
- required
- />
- </div>
- <button type="submit" disabled={inviting || !inviteEmail.trim()} className="btn btn-primary btn-md shrink-0">
- {inviting ? 'Sending…' : (
- <span className="flex items-center gap-1.5">
- <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="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>
- Send Invite
- </span>
- )}
- </button>
- </div>
- {inviteError && (
- <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
- )}
- {inviteSuccess && (
- <span className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</span>
- )}
- {createdLink && (
- <div className="rounded-lg p-3 animate-scale-in"
- style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
- <div className="flex items-center gap-2 mb-1">
- <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <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" />
- </svg>
- <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied to clipboard!</span>
- </div>
- <p className="text-[10px] truncate" style={{ color: 'rgba(134,239,172,0.7)' }}>{createdLink}</p>
- </div>
- )}
- </form>
- </div>
- {/* Pending invitations */}
- <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)' }}>
- {invitations.length}
- </span>
- </div>
- {loading ? (
- <div className="p-8 text-center">
- <div className="w-5 h-5 rounded-full animate-spin mx-auto"
- style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
- </div>
- ) : invitations.length === 0 ? (
- <div className="p-8 text-center">
- <div className="w-12 h-12 rounded-2xl mx-auto mb-3 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-5 h-5" style={{ color: '#6366F1' }} 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>
- <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No pending invitations</p>
- <p className="text-xs mt-1" style={{ color: 'var(--text-subtle)' }}>Use the form above to invite new members</p>
- </div>
- ) : (
- <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
- {invitations.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>
- {inv.type === 'WORKSPACE' ? (
- <span className="badge badge-danger text-[10px]">Workspace</span>
- ) : (
- <span className="badge badge-brand text-[10px]">Project</span>
- )}
- </div>
- <div className="flex items-center gap-2 mt-0.5">
- <span className="text-xs" style={{ color: '#818CF8' }}>
- {inv.type === 'WORKSPACE' ? 'Workspace member' : (inv.project?.name ?? '—')}
- </span>
- <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>·</span>
- <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
- Sent {new Date(inv.createdAt).toLocaleDateString()} · Expires {new Date(inv.expiresAt).toLocaleDateString()}
- </span>
- </div>
- </div>
- {/* Actions */}
- <div className="flex items-center gap-1.5 shrink-0">
- <button
- onClick={() => handleCopy(inv)}
- className="btn btn-secondary btn-sm"
- title="Copy invite link"
- >
- {copiedId === 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>
- )}
- {invitations.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 a link and send it manually to your invitee.
- </p>
- </div>
- )}
- </div>
- </div>
- )}
- </div>
- {/* Delete confirm modal */}
- {confirmDelete && (
- <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"
- style={{ maxWidth: '400px' }}>
- <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text)' }}>
- Delete user?
- </h3>
- <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
- This will permanently delete the user and all their comments. This action cannot be undone.
- </p>
- <div className="flex gap-3 justify-end">
- <button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-md">
- Cancel
- </button>
- <button
- onClick={() => handleDelete(confirmDelete)}
- className="btn btn-danger btn-md"
- >
- Delete
- </button>
- </div>
- </div>
- </div>
- )}
- </div>
- );
- }
|