diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 005ac7b..0328ddc 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -286,8 +286,9 @@ async def delete_user( - No se puede eliminar a sí mismo - No se puede eliminar el último ADMIN del tenant """ - # Verificar permisos - solo ADMIN puede eliminar - if current_user.role != UserRole.ADMIN: + # Verificar permisos - ADMIN global o CLIENT_ADMIN pueden eliminar + allowed = [UserRole.ADMIN, UserRole.CLIENT_ADMIN] + if current_user.role not in allowed: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can delete users" @@ -334,8 +335,8 @@ async def delete_user( detail="Cannot delete the last active admin of the tenant" ) - # Soft delete - db_user.is_active = False + # Hard delete + await db.delete(db_user) await db.commit() # Registrar eliminación en auditoría diff --git a/backend/fix_delete.py b/backend/fix_delete.py new file mode 100644 index 0000000..9ffb5cb --- /dev/null +++ b/backend/fix_delete.py @@ -0,0 +1,15 @@ +with open("/app/app/api/v1/endpoints/users.py", "r") as f: + content = f.read() + +old = ' # Verificar permisos - solo ADMIN puede eliminar\n if current_user.role != UserRole.ADMIN:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )' + +new = ' # Verificar permisos - ADMIN global o CLIENT_ADMIN pueden eliminar\n allowed = [UserRole.ADMIN, UserRole.CLIENT_ADMIN]\n if current_user.role not in allowed:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )' + +if old in content: + content = content.replace(old, new) + print("OK: permisos delete actualizados") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/api/v1/endpoints/users.py", "w") as f: + f.write(content) diff --git a/backend/fix_delete2.py b/backend/fix_delete2.py new file mode 100644 index 0000000..7e4bfe4 --- /dev/null +++ b/backend/fix_delete2.py @@ -0,0 +1,14 @@ +with open("/app/app/api/v1/endpoints/users.py", "r") as f: + content = f.read() + +old = " # Soft delete\n db_user.is_active = False\n await db.commit()" +new = " # Hard delete\n await db.delete(db_user)\n await db.commit()" + +if old in content: + content = content.replace(old, new) + print("OK: hard delete aplicado") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/api/v1/endpoints/users.py", "w") as f: + f.write(content) diff --git a/fix_delete.py b/fix_delete.py new file mode 100644 index 0000000..9ffb5cb --- /dev/null +++ b/fix_delete.py @@ -0,0 +1,15 @@ +with open("/app/app/api/v1/endpoints/users.py", "r") as f: + content = f.read() + +old = ' # Verificar permisos - solo ADMIN puede eliminar\n if current_user.role != UserRole.ADMIN:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )' + +new = ' # Verificar permisos - ADMIN global o CLIENT_ADMIN pueden eliminar\n allowed = [UserRole.ADMIN, UserRole.CLIENT_ADMIN]\n if current_user.role not in allowed:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )' + +if old in content: + content = content.replace(old, new) + print("OK: permisos delete actualizados") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/api/v1/endpoints/users.py", "w") as f: + f.write(content) diff --git a/fix_delete2.py b/fix_delete2.py new file mode 100644 index 0000000..7e4bfe4 --- /dev/null +++ b/fix_delete2.py @@ -0,0 +1,14 @@ +with open("/app/app/api/v1/endpoints/users.py", "r") as f: + content = f.read() + +old = " # Soft delete\n db_user.is_active = False\n await db.commit()" +new = " # Hard delete\n await db.delete(db_user)\n await db.commit()" + +if old in content: + content = content.replace(old, new) + print("OK: hard delete aplicado") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/api/v1/endpoints/users.py", "w") as f: + f.write(content) diff --git a/fix_usuarios.py b/fix_usuarios.py new file mode 100644 index 0000000..22b0f4c --- /dev/null +++ b/fix_usuarios.py @@ -0,0 +1,11 @@ +with open("/app/src/routes/usuarios/+page.svelte", "r") as f: + content = f.read() + +content = content.replace( + "{#if (user as any).can_manage_users && user.role !== 'CLIENT_ADMIN'}", + "{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}" +) + +with open("/app/src/routes/usuarios/+page.svelte", "w") as f: + f.write(content) +print("Listo") diff --git a/frontend-client/fix_usuarios.py b/frontend-client/fix_usuarios.py new file mode 100644 index 0000000..22b0f4c --- /dev/null +++ b/frontend-client/fix_usuarios.py @@ -0,0 +1,11 @@ +with open("/app/src/routes/usuarios/+page.svelte", "r") as f: + content = f.read() + +content = content.replace( + "{#if (user as any).can_manage_users && user.role !== 'CLIENT_ADMIN'}", + "{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}" +) + +with open("/app/src/routes/usuarios/+page.svelte", "w") as f: + f.write(content) +print("Listo") diff --git a/frontend-client/src/lib/components/Header.svelte b/frontend-client/src/lib/components/Header.svelte index 6ce064f..a86b810 100644 --- a/frontend-client/src/lib/components/Header.svelte +++ b/frontend-client/src/lib/components/Header.svelte @@ -18,7 +18,7 @@ function handleLogout() { isMenuOpen = false; - auth.logout(); // El store maneja la redirección automática + auth.logout(); // El store maneja la redirección automática } @@ -49,6 +49,13 @@ > Mis Tickets + + + Usuarios + Mis Tickets + + + Usuarios + + import { onMount } from 'svelte'; + import { auth } from '$lib/stores/auth.js'; + import { toast } from '$lib/stores/toast.js'; + import { goto } from '$app/navigation'; + + // ---- Tipos ---- + interface User { + id: string; + email: string; + first_name: string; + last_name: string; + role: string; + is_active: boolean; + created_at: string; + tenant_id: string; + can_manage_users?: boolean; + } + + // ---- Estado ---- + let users: User[] = []; + let isLoading = true; + let showModal = false; + let showDeleteConfirm = false; + let modalMode: 'create' | 'edit' = 'create'; + let selectedUser: User | null = null; + let searchQuery = ''; + let filterRole = ''; + let filterStatus = ''; + + let form = { + first_name: '', + last_name: '', + email: '', + password: '', + role: 'CLIENT_USER', + is_active: true, + can_manage_users: false + }; + + let isSaving = false; + let isDeleting = false; + + // ---- Permisos ---- + $: currentRole = $auth.user?.role ?? ''; + $: isClientAdmin = currentRole === 'CLIENT_ADMIN'; + $: canManageUsers = isClientAdmin; + + // ---- Utilidades ---- + function apiHeaders(): Record { + const h: Record = { + 'X-App': 'client', + 'X-Tenant-Slug': $auth.user?.tenant_slug || $auth.user?.tenant_id || '', + }; + if ($auth.token) h['Authorization'] = `Bearer ${$auth.token}`; + return h; + } + + // ---- Carga de usuarios ---- + async function loadUsers() { + isLoading = true; + try { + const res = await fetch('/api/v1/users/', { + credentials: 'include', + headers: apiHeaders() + }); + if (!res.ok) throw new Error((await res.json()).detail || 'Error al cargar usuarios'); + users = await res.json(); + } catch (e: any) { + toast.error(e.message); + } finally { + isLoading = false; + } + } + + onMount(async () => { + if (!$auth.isAuthenticated) { goto('/login'); return; } + await loadUsers(); + }); + + // ---- Filtros reactivos ---- + $: filteredUsers = users.filter(u => { + const q = searchQuery.toLowerCase(); + const matchSearch = !q || + u.first_name?.toLowerCase().includes(q) || + u.last_name?.toLowerCase().includes(q) || + u.email?.toLowerCase().includes(q); + const matchRole = !filterRole || u.role === filterRole; + const matchStatus = filterStatus === '' ? true : + filterStatus === 'active' ? u.is_active : !u.is_active; + return matchSearch && matchRole && matchStatus; + }); + + // ---- Modal ---- + function openCreate() { + modalMode = 'create'; + form = { first_name: '', last_name: '', email: '', password: '', role: 'CLIENT_USER', is_active: true, can_manage_users: false }; + showModal = true; + } + + function openEdit(user: User) { + modalMode = 'edit'; + selectedUser = user; + form = { + first_name: user.first_name || '', + last_name: user.last_name || '', + email: user.email, + password: '', + role: user.role, + is_active: user.is_active, + can_manage_users: user.can_manage_users || false + }; + showModal = true; + } + + function closeModal() { + showModal = false; + selectedUser = null; + } + + // ---- CRUD ---- + async function saveUser() { + isSaving = true; + try { + const payload: any = { ...form }; + if (modalMode === 'edit' && !payload.password) delete payload.password; + + const url = modalMode === 'create' + ? '/api/v1/users/' + : `/api/v1/users/${selectedUser?.id}`; + const method = modalMode === 'create' ? 'POST' : 'PUT'; + + const res = await fetch(url, { + method, + credentials: 'include', + headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!res.ok) throw new Error((await res.json()).detail || 'Error al guardar'); + toast.success(modalMode === 'create' ? 'Usuario creado correctamente' : 'Usuario actualizado'); + closeModal(); + await loadUsers(); + } catch (e: any) { + toast.error(e.message); + } finally { + isSaving = false; + } + } + + async function toggleActive(user: User) { + try { + const endpoint = user.is_active + ? `/api/v1/users/${user.id}` + : `/api/v1/users/${user.id}/activate`; + const method = user.is_active ? 'DELETE' : 'PATCH'; + const res = await fetch(endpoint, { + method, + credentials: 'include', + headers: apiHeaders() + }); + if (!res.ok) throw new Error((await res.json()).detail || 'Error'); + toast.success(user.is_active ? 'Usuario desactivado' : 'Usuario activado'); + await loadUsers(); + } catch (e: any) { + toast.error(e.message); + } + } + + async function deleteUser() { + if (!selectedUser) return; + isDeleting = true; + try { + const res = await fetch(`/api/v1/users/${selectedUser.id}`, { + method: 'DELETE', + credentials: 'include', + headers: apiHeaders() + }); + if (!res.ok) throw new Error((await res.json()).detail || 'Error al eliminar'); + toast.success('Usuario eliminado'); + showDeleteConfirm = false; + selectedUser = null; + await loadUsers(); + } catch (e: any) { + toast.error(e.message); + } finally { + isDeleting = false; + } + } + + function confirmDelete(user: User) { + selectedUser = user; + showDeleteConfirm = true; + } + + // ---- Helpers visuales ---- + function roleLabel(role: string): string { + return role === 'CLIENT_ADMIN' ? 'Administrador' : 'Usuario'; + } + + function roleBadgeClass(role: string): string { + return role === 'CLIENT_ADMIN' + ? 'bg-violet-100 text-violet-700 ring-1 ring-violet-200' + : 'bg-sky-50 text-sky-700 ring-1 ring-sky-200'; + } + + function initials(u: User): string { + const f = u.first_name?.[0] || ''; + const l = u.last_name?.[0] || ''; + return (f + l).toUpperCase() || u.email[0].toUpperCase(); + } + + function avatarColor(email: string): string { + const colors = [ + 'bg-rose-400', 'bg-orange-400', 'bg-amber-400', + 'bg-emerald-400', 'bg-teal-400', 'bg-cyan-400', + 'bg-blue-400', 'bg-violet-400', 'bg-pink-400' + ]; + let hash = 0; + for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0); + return colors[Math.abs(hash) % colors.length]; + } + + + + Usuarios - ServiceManager + + +
+ + +
+
+

Usuarios

+

+ Miembros de tu organización· {users.length} en total +

+
+ {#if isClientAdmin} + + {/if} +
+ + +
+
+ + + + +
+ + +
+ + + {#if isLoading} +
+
+
+ + {:else if filteredUsers.length === 0} +
+ + + +

No se encontraron usuarios

+

Intenta ajustar los filtros de búsqueda

+
+ + {:else} +
+ + + + + + + + {#if canManageUsers} + + {/if} + + + + {#each filteredUsers as user (user.id)} + + + + + + + + + + + + + + + {#if canManageUsers} + + {/if} + + {/each} + +
UsuarioRolEstadoAcciones
+
+
+ {initials(user)} +
+
+

+ {user.first_name || ''} {user.last_name || ''} + {#if user.id === $auth.user?.id} + (tú) + {/if} +

+

{user.email}

+
+
+
+ + {roleLabel(user.role)} + + {#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'} + + Gestión + + {/if} + + {#if user.is_active} + + + Activo + + {:else} + + + Inactivo + + {/if} + +
+ + + + + + + + + {#if isClientAdmin && user.id !== $auth.user?.id} + + {/if} + +
+
+
+ {/if} +
+ + +{#if showModal} +
+
+
+ +
+

+ {modalMode === 'create' ? 'Nuevo usuario' : 'Editar usuario'} +

+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + {#if form.role === 'CLIENT_USER'} +
+ + +
+ {/if} + + {#if modalMode === 'edit'} +
+ + +
+ {/if} + +
+ + +
+ +
+
+
+{/if} + + +{#if showDeleteConfirm && selectedUser} +
+
showDeleteConfirm = false}>
+
+
+ + + +
+

Eliminar usuario

+

+ ¿Seguro que quieres eliminar a {selectedUser.first_name} {selectedUser.last_name}? Esta acción no se puede deshacer. +

+
+ + +
+
+
+{/if} \ No newline at end of file