feat(users): enhance user management with company-specific API calls and access validation

This commit is contained in:
2026-01-15 08:10:01 -06:00
parent 86e1f7e8f6
commit 056909b17f
4 changed files with 95 additions and 61 deletions

View File

@@ -8,7 +8,7 @@ import uuid
from pathlib import Path
from core.database import get_core_db
from core.security import get_current_user, get_tenant_from_token
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy.orm import Session
@@ -26,37 +26,11 @@ from .service import UserService
router = APIRouter(prefix="/users", tags=["Users"])
def get_user_service(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
) -> UserService:
"""Dependency para obtener servicio de usuarios con el tenant y company del usuario actual"""
# Obtener keycloak_user_id del usuario actual
keycloak_user_id = current_user.get("sub")
if not keycloak_user_id:
raise HTTPException(status_code=400, detail="User ID not found in token")
# Buscar el user_tenant activo del usuario
user_tenant = (
db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
.first()
)
if not user_tenant:
raise HTTPException(
status_code=400, detail="User does not belong to any tenant"
)
return UserService(db, user_tenant.tenant_id, user_tenant.company_id)
@router.get("/stats", response_model=UserStatsDTO)
def get_user_statistics(
service: UserService = Depends(get_user_service),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene estadísticas de usuarios del tenant actual
@@ -68,21 +42,27 @@ def get_user_statistics(
- Usuarios disponibles
- Porcentaje de uso
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = UserService(db, tenant_id, company_id)
return service.get_user_stats()
@router.get("/", response_model=UserListResponseDTO)
def list_users(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"),
search: Optional[str] = Query(None, description="Término de búsqueda"),
service: UserService = Depends(get_user_service),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Lista todos los usuarios del tenant con paginación
Se puede filtrar por término de búsqueda (busca en username, email, nombre)
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = UserService(db, tenant_id, company_id)
result = service.get_tenant_users(page=page, page_size=page_size, search=search)
return result
@@ -90,20 +70,26 @@ def list_users(
@router.get("/{user_id}", response_model=UserResponseDTO)
def get_user(
user_id: str,
service: UserService = Depends(get_user_service),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene información detallada de un usuario específico
El usuario debe pertenecer al tenant actual
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id)
return service.get_user(user_id)
@router.post("/", response_model=UserResponseDTO, status_code=201)
def create_user(
data: CreateUserRequestDTO,
service: UserService = Depends(get_user_service),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Crea un nuevo usuario en Keycloak y lo asocia al tenant
@@ -115,6 +101,8 @@ def create_user(
Nota: El tenant_id se obtiene automáticamente del servicio (del token del usuario actual)
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"])
service = UserService(db, tenant_id, company_id)
user = service.create_user(
email=data.email,
username=data.username,
@@ -132,7 +120,9 @@ def create_user(
def update_user(
user_id: str,
data: UpdateUserRequestDTO,
service: UserService = Depends(get_user_service),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Actualiza información de un usuario
@@ -144,6 +134,8 @@ def update_user(
- Rol en el tenant
- Perfil (avatar, teléfono, bio, preferencias)
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
service = UserService(db, tenant_id, company_id)
user = service.update_user(
user_id=user_id,
first_name=data.first_name,
@@ -163,11 +155,13 @@ def update_user(
@router.delete("/{user_id}")
def delete_user(
user_id: str,
company_id: int = Query(..., description="Company ID"),
soft_delete: bool = Query(
True,
description="Si es True, solo desactiva. Si es False, elimina permanentemente",
),
service: UserService = Depends(get_user_service),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Elimina un usuario del tenant
@@ -175,6 +169,8 @@ def delete_user(
- soft_delete=True: Solo desactiva la relación (recomendado)
- soft_delete=False: Elimina permanentemente de Keycloak
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"])
service = UserService(db, tenant_id, company_id)
service.delete_user(user_id, soft_delete=soft_delete)
return {"message": "User deleted successfully"}
@@ -183,7 +179,9 @@ def delete_user(
def change_user_password(
user_id: str,
data: ChangePasswordRequestDTO,
service: UserService = Depends(get_user_service),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Cambia la contraseña de un usuario
@@ -191,6 +189,8 @@ def change_user_password(
- temporary=True: Usuario debe cambiar la contraseña en el próximo login
- temporary=False: Contraseña permanente
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = UserService(db, tenant_id, company_id)
service.change_password(user_id, data.password, data.temporary)
return {"message": "Password changed successfully"}

View File

@@ -180,7 +180,7 @@ def validate_access_to_resource(
company_id: int,
current_user: Dict[str, Any],
required_permissions: Optional[list[str]] = None,
require_all: bool = False,
require_all: bool = True,
) -> int:
"""
Valida que el usuario tenga acceso a un recurso específico basado en company_id

View File

@@ -62,8 +62,8 @@ export const usersAPI = {
/**
* Obtiene estadísticas de usuarios del tenant
*/
async getStats(): Promise<UserStats> {
const response = await api.get<UserStats>('/v1/core/users/stats');
async getStats(companyId: number): Promise<UserStats> {
const response = await api.get<UserStats>(`/v1/core/users/stats?company_id=${companyId}`);
if (response.error) {
throw new Error(response.error);
}
@@ -73,17 +73,18 @@ export const usersAPI = {
/**
* Lista usuarios del tenant con paginación
*/
async list(params?: {
async list(companyId: number, params?: {
page?: number;
page_size?: number;
search?: string;
}): Promise<UserListResponse> {
const queryParams = new URLSearchParams();
queryParams.set('company_id', companyId.toString());
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
if (params?.search) queryParams.set('search', params.search);
const endpoint = `/v1/core/users/${queryParams.toString() ? `?${queryParams}` : ''}`;
const endpoint = `/v1/core/users/?${queryParams}`;
const response = await api.get<UserListResponse>(endpoint);
if (response.error) {
throw new Error(response.error);
@@ -94,8 +95,8 @@ export const usersAPI = {
/**
* Obtiene un usuario específico
*/
async get(userId: string): Promise<User> {
const response = await api.get<User>(`/v1/core/users/${userId}`);
async get(userId: string, companyId: number): Promise<User> {
const response = await api.get<User>(`/v1/core/users/${userId}?company_id=${companyId}`);
if (response.error) {
throw new Error(response.error);
}
@@ -105,8 +106,8 @@ export const usersAPI = {
/**
* Crea un nuevo usuario
*/
async create(data: CreateUserRequest): Promise<User> {
const response = await api.post<User>('/v1/core/users/', data);
async create(data: CreateUserRequest, companyId: number): Promise<User> {
const response = await api.post<User>(`/v1/core/users/?company_id=${companyId}`, data);
if (response.error) {
throw new Error(response.error);
}
@@ -116,8 +117,8 @@ export const usersAPI = {
/**
* Actualiza un usuario existente
*/
async update(userId: string, data: UpdateUserRequest): Promise<User> {
const response = await api.put<User>(`/v1/core/users/${userId}`, data);
async update(userId: string, data: UpdateUserRequest, companyId: number): Promise<User> {
const response = await api.put<User>(`/v1/core/users/${userId}?company_id=${companyId}`, data);
if (response.error) {
throw new Error(response.error);
}
@@ -127,8 +128,9 @@ export const usersAPI = {
/**
* Elimina un usuario
*/
async delete(userId: string, softDelete: boolean = true): Promise<void> {
async delete(userId: string, companyId: number, softDelete: boolean = true): Promise<void> {
const queryParams = new URLSearchParams();
queryParams.set('company_id', companyId.toString());
queryParams.set('soft_delete', softDelete.toString());
const response = await api.delete(`/v1/core/users/${userId}?${queryParams}`);
@@ -140,8 +142,8 @@ export const usersAPI = {
/**
* Cambia la contraseña de un usuario
*/
async changePassword(userId: string, data: ChangePasswordRequest): Promise<void> {
const response = await api.post(`/v1/core/users/${userId}/change-password`, data);
async changePassword(userId: string, data: ChangePasswordRequest, companyId: number): Promise<void> {
const response = await api.post(`/v1/core/users/${userId}/change-password?company_id=${companyId}`, data);
if (response.error) {
throw new Error(response.error);
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { usersAPI, type User, type UserStats } from '$lib/api/dashboard/users';
import { companyStore } from '$lib/stores/company.svelte';
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
@@ -73,8 +73,11 @@
// Cargar estadísticas
async function loadStats() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
try {
stats = await usersAPI.getStats();
stats = await usersAPI.getStats(companyId);
} catch (error: any) {
console.error('Error loading stats:', error);
toast.error('Error al cargar estadísticas', {
@@ -85,9 +88,12 @@
// Cargar usuarios
async function loadUsers() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
loading = true;
try {
const response = await usersAPI.list({
const response = await usersAPI.list(companyId, {
page: currentPage,
page_size: pageSize,
search: searchTerm || undefined
@@ -128,9 +134,14 @@
return;
}
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
toast.error('No hay compañía seleccionada');
return;
}
try {
// El tenant_id se obtiene automáticamente del token en el backend
await usersAPI.create(createForm);
await usersAPI.create(createForm, companyId);
toast.success('Usuario creado exitosamente');
showCreateDialog = false;
@@ -162,6 +173,12 @@
async function handleUpdate() {
if (!selectedUser) return;
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
toast.error('No hay compañía seleccionada');
return;
}
try {
// Filtrar campos vacíos antes de enviar
const updateData: any = {};
@@ -185,7 +202,7 @@
updateData.email_verified = editForm.email_verified;
}
await usersAPI.update(selectedUser.id, updateData);
await usersAPI.update(selectedUser.id, updateData, companyId);
toast.success('Usuario actualizado exitosamente');
showEditDialog = false;
selectedUser = null;
@@ -208,8 +225,14 @@
async function handleDelete(softDelete: boolean = true) {
if (!selectedUser) return;
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
toast.error('No hay compañía seleccionada');
return;
}
try {
await usersAPI.delete(selectedUser.id, softDelete);
await usersAPI.delete(selectedUser.id, companyId, softDelete);
toast.success(softDelete ? 'Usuario desactivado exitosamente' : 'Usuario eliminado permanentemente');
showDeleteDialog = false;
selectedUser = null;
@@ -247,11 +270,17 @@
return;
}
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
toast.error('No hay compañía seleccionada');
return;
}
try {
await usersAPI.changePassword(selectedUser.id, {
password: passwordForm.password,
temporary: passwordForm.temporary
});
}, companyId);
toast.success('Contraseña actualizada exitosamente');
showPasswordDialog = false;
selectedUser = null;
@@ -286,9 +315,12 @@
}
}
// Cargar datos al montar
onMount(() => {
Promise.all([loadUsers(), loadStats()]);
// Cargar datos reactivamente cuando cambia la compañía
$effect(() => {
const companyId = companyStore.activeCompany?.id;
if (companyId) {
Promise.all([loadUsers(), loadStats()]);
}
});
</script>