/** * Limitaciones conocidas: * - "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión. */ import { useEffect, useState } from 'react' import * as DialogPrimitive from '@radix-ui/react-dialog' import { UserX, UserPlus, MoreVertical, Trash2 } from 'lucide-react' import { supabase } from '@/lib/supabase' import { useAuth } from '@/auth/AuthContext' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter, } from '@/components/ui/sheet' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { useToast } from '@/components/ui/use-toast' import { RoleBadge } from './RoleBadge' interface AdminUser { id: string name: string | null company: string | null platform_role: string org_id: string | null avatar_url: string | null organization: { name: string; is_provider: boolean } | null email?: string | null } interface OrgOption { id: string name: string } // ─── ConfirmDialog usando @radix-ui/react-dialog (sin @radix-ui/react-alert-dialog) ── interface ConfirmDialogProps { open: boolean onOpenChange: (open: boolean) => void title: string description: string confirmLabel?: string onConfirm: () => void loading?: boolean destructive?: boolean } function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel = 'Confirmar', onConfirm, loading = false, destructive = false, }: ConfirmDialogProps) { return ( e.preventDefault()} > {title} {description}
) } // ─── Página principal ───────────────────────────────────────────────────────── const INVITE_ROLES = ['client_editor', 'client_viewer', 'member'] as const type InviteRole = typeof INVITE_ROLES[number] const CHANGE_ROLES = ['platform_admin', 'member', 'client_editor', 'client_viewer', 'guest'] as const export function UsersPage() { const { user: adminUser } = useAuth() const { toast } = useToast() const [users, setUsers] = useState([]) const [orgs, setOrgs] = useState([]) const [loadingUsers, setLoadingUsers] = useState(true) const [filterOrgId, setFilterOrgId] = useState('all') // Invite sheet const [inviteOpen, setInviteOpen] = useState(false) const [inviteName, setInviteName] = useState('') const [inviteEmail, setInviteEmail] = useState('') const [inviteRole, setInviteRole] = useState('client_editor') const [inviteOrgName, setInviteOrgName] = useState('') const [inviteError, setInviteError] = useState(null) const [inviteSuccess, setInviteSuccess] = useState(null) const [inviting, setInviting] = useState(false) // Change role sheet const [changeRoleUser, setChangeRoleUser] = useState(null) const [changeRoleOpen, setChangeRoleOpen] = useState(false) const [newRole, setNewRole] = useState('') const [changingRole, setChangingRole] = useState(false) const [changeRoleError, setChangeRoleError] = useState(null) // Confirmations const [suspendTarget, setSuspendTarget] = useState(null) const [suspending, setSuspending] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) const [deleting, setDeleting] = useState(false) const [reactivateTarget, setReactivateTarget] = useState(null) const [reactivating, setReactivating] = useState(false) const fetchUsers = async () => { const query = supabase .from('users') .select('id, name, company, platform_role, org_id, avatar_url, organization:organizations(name, is_provider)') .order('name') const finalQuery = filterOrgId !== 'all' ? query.eq('org_id', filterOrgId) : query const [{ data }, { data: emailData }] = await Promise.all([ finalQuery, supabase.functions.invoke('list-users'), ]) const emailMap = new Map() if (emailData) { for (const u of (emailData as Array<{ id: string; email: string | null }>) ?? []) { emailMap.set(u.id, u.email ?? null) } } const usersWithEmail = ((data as AdminUser[] | null) ?? []).map((u) => ({ ...u, email: emailMap.get(u.id) ?? null, })) setUsers(usersWithEmail) setLoadingUsers(false) } const fetchOrgs = async () => { const { data } = await supabase .from('organizations') .select('id, name') .order('name') setOrgs((data as OrgOption[] | null) ?? []) } useEffect(() => { void fetchOrgs() }, []) useEffect(() => { setLoadingUsers(true) void fetchUsers() }, [filterOrgId]) // eslint-disable-line react-hooks/exhaustive-deps // ── Invite user ────────────────────────────────────────────────────────── const openInvite = () => { setInviteName('') setInviteEmail('') setInviteRole('client_editor') setInviteOrgName('') setInviteError(null) setInviteSuccess(null) setInviteOpen(true) } const handleInvite = async (e: React.FormEvent) => { e.preventDefault() setInviteError(null) setInviteSuccess(null) if (!inviteName.trim()) { setInviteError('El nombre es requerido'); return } if (!inviteEmail.trim() || !inviteEmail.includes('@')) { setInviteError('Email inválido'); return } if ((inviteRole === 'client_editor' || inviteRole === 'client_viewer') && !inviteOrgName.trim()) { setInviteError('La organización es requerida para este rol') return } setInviting(true) const { error } = await supabase.functions.invoke('invite-user', { body: { email: inviteEmail.trim(), name: inviteName.trim(), platform_role: inviteRole, org_name: inviteRole === 'member' ? 'InQuality' : inviteOrgName.trim(), }, }) if (error) { setInviteError(typeof error === 'object' && 'message' in error ? String(error.message) : String(error)) setInviting(false) } else { setInviteSuccess(`Invitación enviada a ${inviteEmail.trim()}`) setInviting(false) void fetchUsers() } } // ── Change role ────────────────────────────────────────────────────────── const openChangeRole = (u: AdminUser) => { setChangeRoleUser(u) setNewRole(u.platform_role) setChangeRoleError(null) setChangeRoleOpen(true) } const handleChangeRole = async () => { if (!changeRoleUser || !newRole) return setChangingRole(true) const { error } = await supabase .from('users') .update({ platform_role: newRole }) .eq('id', changeRoleUser.id) if (error) { setChangeRoleError(error.message) setChangingRole(false) } else { setChangeRoleOpen(false) setChangingRole(false) void fetchUsers() } } // ── Suspend (revoke-user vía Edge Function) ─────────────────────────────── const handleSuspend = async () => { if (!suspendTarget) return setSuspending(true) const { error } = await supabase.functions.invoke('revoke-user', { body: { userId: suspendTarget.id }, }) if (error) { console.error('Error al suspender usuario:', error) } setSuspendTarget(null) setSuspending(false) void fetchUsers() } // ── Hard delete vía Edge Function delete-user ───────────────────────────── const handleDelete = async () => { if (!deleteTarget) return setDeleting(true) const { error } = await supabase.functions.invoke('delete-user', { body: { userId: deleteTarget.id }, }) if (error) { toast({ title: 'Error al eliminar usuario', description: typeof error === 'object' && 'message' in error ? String(error.message) : 'Ocurrió un error inesperado.', variant: 'destructive', }) } else { toast({ title: 'Usuario eliminado permanentemente' }) } setDeleteTarget(null) setDeleting(false) void fetchUsers() } // ── Reactivate (restore to client_editor — rol previo no se guarda) ─────── const handleReactivate = async () => { if (!reactivateTarget) return setReactivating(true) await supabase .from('users') .update({ platform_role: 'client_editor' }) .eq('id', reactivateTarget.id) setReactivateTarget(null) setReactivating(false) void fetchUsers() } // ───────────────────────────────────────────────────────────────────────── const displayedUsers = users return ( <>

Usuarios

{loadingUsers ? ( ) : displayedUsers.length === 0 ? ( } message={ filterOrgId !== 'all' ? 'No hay usuarios en esta organización.' : 'No hay usuarios registrados aún.' } /> ) : (
{displayedUsers.map((u) => { const isSelf = adminUser?.id === u.id const isPending = !u.name && !u.avatar_url return ( ) })}
Nombre Email Rol Organización
{u.name || Sin nombre} {isPending && ( Invitación pendiente )} {isSelf && ( )}
{u.email ?? '—'} {u.organization?.name ?? '—'} openChangeRole(u)}> Cambiar rol {u.platform_role === 'guest' ? ( setReactivateTarget(u)}> Reactivar usuario ) : ( setSuspendTarget(u)} > Suspender )} setDeleteTarget(u)} > Eliminar permanentemente
)} {/* ─── Sheet: Invitar usuario ──────────────────────────────────────── */} Invitar usuario
setInviteName(e.target.value)} placeholder="Ej. Ana Martínez" disabled={inviting} autoFocus /> setInviteEmail(e.target.value)} placeholder="ana@empresa.com" disabled={inviting} /> {inviteRole === 'member' ? ( ) : ( setInviteOrgName(e.target.value)} placeholder="Nombre de la organización" disabled={inviting} /> {orgs.map((o) => ( )} {inviteError &&

{inviteError}

} {inviteSuccess &&

{inviteSuccess}

}
{/* ─── Sheet: Cambiar rol ──────────────────────────────────────────── */} Cambiar rol — {changeRoleUser?.name}
{changeRoleError &&

{changeRoleError}

}
{/* ─── ConfirmDialog: Suspender ────────────────────────────────────── */} !open && setSuspendTarget(null)} title={`¿Suspender a ${suspendTarget?.name ?? 'este usuario'}?`} description="No podrá acceder a la plataforma mientras esté suspendido. Podés reactivarlo después." confirmLabel="Suspender" onConfirm={handleSuspend} loading={suspending} destructive /> {/* ─── ConfirmDialog: Eliminar permanentemente ────────────────────── */} !open && setDeleteTarget(null)} title={`¿Eliminar permanentemente a ${deleteTarget?.name ?? 'este usuario'}?`} description="Esta acción no puede deshacerse. El usuario perderá acceso inmediatamente y sus datos de autenticación serán eliminados de forma permanente." confirmLabel="Eliminar permanentemente" onConfirm={handleDelete} loading={deleting} destructive /> {/* ─── ConfirmDialog: Reactivar ────────────────────────────────────── */} !open && setReactivateTarget(null)} title={`¿Reactivar a ${reactivateTarget?.name ?? 'este usuario'}?`} description="Se le asignará el rol Client Editor. (Nota: el rol original no se conservó al momento de la suspensión.)" confirmLabel="Reactivar" onConfirm={handleReactivate} loading={reactivating} /> ) } function Field({ label, id, children }: { label: string; id: string; children: React.ReactNode }) { return (
{children}
) } function TableSkeleton({ rows }: { rows: number }) { return (
{Array.from({ length: rows }).map((_, i) => (
))}
) } function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) { return (
{icon}

{message}

) }