feat(users): enhance user management with company-specific API calls and access validation
This commit is contained in:
@@ -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"}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user