Compare commits

..

8 Commits

Author SHA1 Message Date
3f7b166767 Sistema limpio 2026-03-10 13:46:30 -06:00
6bc5145b9c fix: tenant dinamico desde BD - sin hardcodeo en frontend cliente 2026-03-10 13:45:23 -06:00
597286fff0 fix: corrige tenant_slug hardcodeado en login de ambos frontends 2026-03-10 13:26:52 -06:00
e141701567 limpieza de scipts 2026-03-09 13:47:20 -06:00
7003e5cd80 limpieza de scipts 2026-03-09 13:40:28 -06:00
d8232fda7c tenant arreglado 2026-03-05 14:04:04 -07:00
16b3dc5d47 Sesiones correctas 2026-03-04 13:31:38 -07:00
3b46f48655 Roles 2026-03-03 14:02:18 -07:00
54 changed files with 2326 additions and 761 deletions

View File

@@ -89,9 +89,10 @@ async def get_current_user(
raise HTTPException(status_code=400, detail="Inactive user")
# Enforce that tenant header (if present) matches the authenticated user's tenant.
# Prevents cross-tenant header impersonation.
# Roles globales (is_global) pueden operar en cualquier tenant → omitir chequeo.
# Roles de cliente (is_client) deben coincidir con su propio tenant.
request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None)
if request_tenant_id and str(user.tenant_id) != str(request_tenant_id):
if request_tenant_id and user.role.is_client and str(user.tenant_id) != str(request_tenant_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant header does not match authenticated user",

View File

@@ -1,7 +1,7 @@
"""
Auth Schemas - ServiceManagerWeb
Pydantic schemas para autenticación y autorización.
Pydantic schemas para autenticación y autorización.
"""
from pydantic import BaseModel, EmailStr
@@ -12,7 +12,7 @@ class LoginRequest(BaseModel):
"""Schema para solicitud de login."""
email: EmailStr
password: str
tenant_slug: str
tenant_slug: Optional[str] = None
totp_code: Optional[str] = None
@@ -53,28 +53,28 @@ class TwoFactorSetupResponse(BaseModel):
class TwoFactorEnableRequest(BaseModel):
"""Código TOTP para confirmar y activar 2FA."""
"""Código TOTP para confirmar y activar 2FA."""
totp_code: str
class TwoFactorEnableResponse(BaseModel):
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
enabled: bool
backup_codes: List[str]
class TwoFactorDisableRequest(BaseModel):
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
totp_code: Optional[str] = None
backup_code: Optional[str] = None
# ============================================================
# Cambio de contraseña
# Cambio de contraseña
# ============================================================
class ChangePasswordRequest(BaseModel):
"""Schema para cambio de contraseña del usuario autenticado."""
"""Schema para cambio de contraseña del usuario autenticado."""
current_password: str
new_password: str
@@ -82,15 +82,15 @@ class ChangePasswordRequest(BaseModel):
# ============================================================
# Recuperación de contraseña
# Recuperación de contraseña
# ============================================================
class ForgotPasswordRequest(BaseModel):
"""Solicitar enlace de reseteo de contraseña por email."""
"""Solicitar enlace de reseteo de contraseña por email."""
email: EmailStr
class ResetPasswordRequest(BaseModel):
"""Aplicar nueva contraseña usando token de reseteo."""
"""Aplicar nueva contraseña usando token de reseteo."""
token: str
new_password: str

View File

@@ -1,7 +1,7 @@
"""
Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
@@ -28,7 +28,7 @@ CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
def _cookie_name_for_role(role: str) -> str:
"""Devuelve el nombre de cookie según el rol del usuario."""
"""Devuelve el nombre de cookie según el rol del usuario."""
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
from app.api.schemas.auth import (
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
@@ -88,25 +88,23 @@ async def login(
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
)
# 1. Validar tenant
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
logger.warning(
"Login failed - tenant not found",
email=login_data.email,
tenant_slug=login_data.tenant_slug,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
# 1. Validar tenant - por slug si viene, sino buscar por email
if login_data.tenant_slug:
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
else:
tenant = None
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
ident_count = await cache.incr(ident_key, 1)
@@ -142,22 +140,25 @@ async def login(
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
)
# 2. Buscar usuario en base de datos (aislado por tenant)
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
# 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
if tenant:
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
else:
query = select(User).where(User.email == login_data.email)
result = await db.execute(query)
user = result.scalar_one_or_none()
# 3. Verificar usuario y contraseña
# 3. Verificar usuario y contraseña
if not user or not security.verify_password(login_data.password, user.password_hash):
logger.warning(
"Login failed - invalid credentials",
email=login_data.email
)
# Registrar intento fallido en auditoría (si el usuario existe)
# Registrar intento fallido en auditoría (si el usuario existe)
if user:
try:
await AuditService.log(
@@ -178,7 +179,7 @@ async def login(
detail="Invalid credentials",
)
# 4. Verificar si está activo
# 4. Verificar si está activo
if not user.is_active:
logger.warning(
"Login failed - user inactive",
@@ -189,19 +190,19 @@ async def login(
detail="User inactive",
)
# 5. Verificar 2FA si está habilitado
# 5. Verificar 2FA si está habilitado
if user.totp_enabled:
if not login_data.totp_code:
# Indicar al frontend que debe pedir el código TOTP
# Indicar al frontend que debe pedir el código TOTP
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
)
if not security.verify_totp(user.totp_secret, login_data.totp_code):
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Código 2FA inválido o expirado"
detail="Código 2FA inválido o expirado"
)
# Create tokens
@@ -233,7 +234,7 @@ async def login(
detail="Service temporarily unavailable",
)
# Registrar login exitoso en auditoría
# Registrar login exitoso en auditoría
try:
await AuditService.log(
db=db,
@@ -283,6 +284,7 @@ async def login(
"last_name": user.last_name,
"role": user.role,
"tenant_id": str(user.tenant_id),
"tenant_slug": tenant.slug if tenant else str(user.tenant_id),
"is_active": user.is_active,
"is_two_factor_enabled": user.totp_enabled or False,
"created_at": user.created_at.isoformat() if user.created_at else None
@@ -392,7 +394,7 @@ async def logout(
except Exception as e:
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
# Registrar logout en auditoría
# Registrar logout en auditoría
try:
import uuid
user_id = uuid.UUID(payload["sub"])
@@ -413,7 +415,7 @@ async def logout(
logger.info("Logout successful", user_id=payload["sub"])
# Borrar la cookie correcta según el rol del usuario
# Borrar la cookie correcta según el rol del usuario
cookie_name = _cookie_name_for_role(payload.get("role", ""))
response.delete_cookie(key=cookie_name)
return {"message": "Successfully logged out"}
@@ -502,7 +504,7 @@ async def get_2fa_status(
current_user: User = Depends(get_current_user),
):
"""
Consultar si el 2FA está habilitado para el usuario actual.
Consultar si el 2FA está habilitado para el usuario actual.
Returns:
Estado de 2FA del usuario autenticado.
@@ -516,10 +518,10 @@ async def setup_2fa(
db: AsyncSession = Depends(get_db),
):
"""
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
El secret se guarda en BD pero 2FA NO se activa todavía.
Se necesita llamar a /2fa/enable con un código válido para activarlo.
El secret se guarda en BD pero 2FA NO se activa todavía.
Se necesita llamar a /2fa/enable con un código válido para activarlo.
Returns:
Secret y QR URI para escanear con la app autenticadora.
@@ -527,7 +529,7 @@ async def setup_2fa(
new_secret = security.generate_totp_secret()
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
# Guardar el secret (sin habilitar aún)
# Guardar el secret (sin habilitar aún)
current_user.totp_secret = new_secret
await db.commit()
@@ -543,29 +545,29 @@ async def enable_2fa(
db: AsyncSession = Depends(get_db),
):
"""
Activar 2FA verificando que el usuario escaneó correctamente el QR.
Activar 2FA verificando que el usuario escaneó correctamente el QR.
Requiere que /2fa/setup haya sido llamado previamente.
Args:
data: Código TOTP generado por la app autenticadora.
data: Código TOTP generado por la app autenticadora.
Returns:
Confirmación y lista de códigos de respaldo.
Confirmación y lista de códigos de respaldo.
"""
if not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Primero inicia el proceso de configuración con /2fa/setup"
detail="Primero inicia el proceso de configuración con /2fa/setup"
)
if not security.verify_totp(current_user.totp_secret, data.totp_code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
)
# Activar 2FA y generar códigos de respaldo
# Activar 2FA y generar códigos de respaldo
backup_codes = security.generate_backup_codes()
current_user.totp_enabled = True
current_user.backup_codes = backup_codes
@@ -593,21 +595,21 @@ async def disable_2fa(
db: AsyncSession = Depends(get_db),
):
"""
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
Args:
data: totp_code o backup_code para verificar identidad.
Returns:
Mensaje de confirmación.
Mensaje de confirmación.
"""
if not current_user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El 2FA no está habilitado en esta cuenta"
detail="El 2FA no está habilitado en esta cuenta"
)
# Verificar con TOTP o código de respaldo
# Verificar con TOTP o código de respaldo
verified = False
if data.totp_code:
@@ -615,7 +617,7 @@ async def disable_2fa(
elif data.backup_code and current_user.backup_codes:
if data.backup_code in current_user.backup_codes:
verified = True
# Invalidar el código de respaldo usado
# Invalidar el código de respaldo usado
current_user.backup_codes = [
c for c in current_user.backup_codes if c != data.backup_code
]
@@ -623,7 +625,7 @@ async def disable_2fa(
if not verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
)
# Deshabilitar 2FA
@@ -644,7 +646,7 @@ async def disable_2fa(
logger.info("2FA disabled", user_id=str(current_user.id))
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
@router.post("/change-password", status_code=status.HTTP_200_OK)
@@ -654,32 +656,32 @@ async def change_password(
db: AsyncSession = Depends(get_db),
):
"""
Cambiar la contraseña del usuario autenticado.
Cambiar la contraseña del usuario autenticado.
Verifica la contraseña actual antes de actualizar.
Requiere autenticación activa.
Verifica la contraseña actual antes de actualizar.
Requiere autenticación activa.
"""
from datetime import datetime
# Validar longitud mínima
# Validar longitud mínima
if len(data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña debe tener al menos 8 caracteres"
detail="La nueva contraseña debe tener al menos 8 caracteres"
)
# Verificar que la contraseña actual sea correcta
# Verificar que la contraseña actual sea correcta
if not security.verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña actual es incorrecta"
detail="La contraseña actual es incorrecta"
)
# No permitir que la nueva sea igual a la actual
if security.verify_password(data.new_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña no puede ser igual a la actual"
detail="La nueva contraseña no puede ser igual a la actual"
)
current_user.password_hash = security.hash_password(data.new_password)
@@ -697,11 +699,11 @@ async def change_password(
await db.commit()
logger.info("Password changed", user_id=str(current_user.id))
return {"message": "Contraseña actualizada correctamente"}
return {"message": "Contraseña actualizada correctamente"}
# ============================================================
# Recuperación de contraseña (forgot / reset)
# Recuperación de contraseña (forgot / reset)
# ============================================================
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
@@ -716,10 +718,10 @@ async def forgot_password(
db: AsyncSession = Depends(get_db),
):
"""
Solicitar reseteo de contraseña.
Solicitar reseteo de contraseña.
Siempre retorna 200 aunque el email no exista, para no revelar
si una dirección está registrada en el sistema.
si una dirección está registrada en el sistema.
"""
import secrets
from redis.asyncio import from_url as redis_from_url
@@ -735,9 +737,9 @@ async def forgot_password(
user = result.scalar_one_or_none()
if not user:
# Respuesta idéntica no revelar existencia
# Respuesta idéntica — no revelar existencia
logger.info("Forgot password: email not found", email=data.email)
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
# Generar token seguro
token = secrets.token_urlsafe(32)
@@ -757,7 +759,7 @@ async def forgot_password(
await send_email(
to_email=user.email,
subject="Restablece tu contraseña — ServiceManager",
subject="Restablece tu contraseña — ServiceManager",
html_content=html,
text_content=text,
)
@@ -774,7 +776,7 @@ async def forgot_password(
await db.commit()
logger.info("Password reset email sent", user_id=str(user.id))
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
@router.post("/reset-password", status_code=status.HTTP_200_OK)
@@ -785,7 +787,7 @@ async def reset_password(
db: AsyncSession = Depends(get_db),
):
"""
Aplicar nueva contraseña usando el token recibido por email.
Aplicar nueva contraseña usando el token recibido por email.
El token es de un solo uso: se elimina de Redis al usarse.
"""
@@ -796,7 +798,7 @@ async def reset_password(
if len(data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña debe tener al menos 8 caracteres"
detail="La contraseña debe tener al menos 8 caracteres"
)
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
@@ -807,7 +809,7 @@ async def reset_password(
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
)
# Eliminar token inmediatamente (un solo uso)
@@ -838,4 +840,4 @@ async def reset_password(
await db.commit()
logger.info("Password reset completed", user_id=str(user.id))
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}

View File

@@ -11,7 +11,7 @@ import uuid
from app.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.user import User
from app.models.user import User, UserRole
from app.models.tenant import Tenant
from app.models.category import Category
from app.models.system import System
@@ -28,98 +28,27 @@ from app.api.v1.helpers import (
safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict
)
from app.services.audit_service import AuditService
from app.services.ticket_service import TicketService, get_ticket_service
router = APIRouter()
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
async def create_ticket(
ticket: TicketCreate,
current_user: User = Depends(get_current_user),
ticket_service: TicketService = Depends(get_ticket_service),
):
"""Crear un nuevo ticket"""
max_retries = 3
last_error = None
for attempt in range(max_retries):
try:
ticket_number = await generate_next_ticket_number(db, current_user.tenant_id)
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
category = None
if category_uuid:
category = await db.get(Category, category_uuid)
if not category:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
# ✅ SECURITY: Validate category belongs to current tenant (prevents cross-tenant category injection)
if category.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
if system_uuid:
system = await db.get(System, system_uuid)
if not system:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
# ✅ SECURITY: Validate system belongs to current tenant (prevents cross-tenant system injection)
if system.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
db_ticket = Ticket(
id=uuid.uuid4(), tenant_id=current_user.tenant_id, ticket_number=ticket_number,
subject=ticket.subject, description=ticket.description, category_id=category_uuid,
affected_system_id=system_uuid, priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id, assigned_to=assigned_to_user, status=TicketStatus.NEW,
sla_response_due=sla_response_due, sla_resolution_due=sla_resolution_due,
created_at=datetime.utcnow(), updated_at=datetime.utcnow()
)
db.add(db_ticket)
await db.commit()
await db.refresh(db_ticket)
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
action="ticket.create", resource_type="ticket", resource_id=db_ticket.id,
new_values={"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
"priority": db_ticket.priority.value, "status": db_ticket.status.value})
return {
"id": str(db_ticket.id), "ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
"title": db_ticket.subject, "description": db_ticket.description, "status": db_ticket.status.value,
"priority": db_ticket.priority.value, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"contact_email": ticket.contact_email,
"contact_phone": ticket.contact_phone,
"created_by": str(db_ticket.created_by), "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at,
"sla_response_due": db_ticket.sla_response_due,
"sla_resolution_due": db_ticket.sla_resolution_due,
"first_response_at": db_ticket.first_response_at,
"resolved_at": db_ticket.resolved_at,
}
except ValueError as e:
await db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid UUID format: {str(e)}")
except HTTPException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
last_error = e
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
if attempt < max_retries - 1:
continue
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Error creating ticket: {str(e)}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}")
return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id)
@router.get("/", response_model=List[TicketResponse])
async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = None, priority: Optional[str] = None,
db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Obtener tickets con filtros opcionales"""
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
# Solo CLIENT_USER ve únicamente sus propios tickets.
# CLIENT_ADMIN ve todos los del tenant.
if current_user.role == UserRole.CLIENT_USER:
query = query.where(Ticket.created_by == current_user.id)
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
@@ -142,14 +71,14 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None,
date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función")
query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id)
# SUPPORT_MANAGER solo ve su propio tenant.
# ADMIN ve todos los tenants (es el administrador de la plataforma).
if current_user.role == "SUPPORT_MANAGER":
if current_user.role == UserRole.SUPPORT_MANAGER:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
@@ -207,7 +136,7 @@ async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current
"""Obtener un ticket por ID"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id)
query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user))
@@ -224,7 +153,7 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
"""Actualizar un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)

View File

@@ -45,8 +45,12 @@ async def read_users(
- role: filtrar por rol
- is_active: filtrar por estado activo
"""
# ✅ CORREGIDO: Filtrar por tenant_id
query = select(User).where(User.tenant_id == current_user.tenant_id)
# ADMIN global ve todos los tenants; el resto solo ve su propio tenant
from app.models.user import UserRole as _UserRole
if current_user.role != _UserRole.ADMIN:
query = select(User).where(User.tenant_id == current_user.tenant_id)
else:
query = select(User)
# Aplicar filtros opcionales
if role:
@@ -144,10 +148,13 @@ async def read_user(
✅ Implementa multi-tenancy: solo permite acceso a usuarios del propio tenant.
"""
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant
)
if current_user.role.value == 'ADMIN':
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
user = result.scalar_one_or_none()
@@ -183,11 +190,14 @@ async def update_user(
detail="You don't have permission to update users"
)
# Buscar usuario
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
# Buscar usuario - ADMIN global puede editar cualquier tenant
if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
@@ -290,11 +300,14 @@ async def delete_user(
detail="You cannot delete yourself"
)
# Buscar usuario
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
# Buscar usuario - ADMIN global puede editar cualquier tenant
if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
@@ -367,11 +380,14 @@ async def activate_user(
detail="You don't have permission to activate users"
)
# Buscar usuario
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
# Buscar usuario - ADMIN global puede editar cualquier tenant
if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()

View File

@@ -68,11 +68,11 @@ async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) ->
last_ticket_number = result.scalar_one_or_none()
if last_ticket_number:
last_number = int(last_ticket_number.split('-')[1])
last_number = int(last_ticket_number.split('-')[-1])
next_number = last_number + 1
else:
next_number = 1
return f"TK-{next_number:06d}"

View File

@@ -0,0 +1,63 @@
"""
Definición y helpers de roles para el sistema multi-tenant.
Fuente única: UserRole en app.models.user.
Este módulo expone conjuntos de roles y helpers de verificación
para usarse en deps.py y en los endpoints.
Roles globales (staff interno — alcance multi-tenant):
ADMIN → control total sobre todos los tenants
SUPPORT_MANAGER → gestiona equipos y SLAs de todos los tenants
AGENT → atiende tickets de cualquier tenant
AUDITOR → auditoría de solo lectura en todos los tenants
Roles de cliente (alcance limitado al propio tenant):
CLIENT_ADMIN → administra organización: usuarios, configuración, tickets
CLIENT_USER → crea y sigue sus propios tickets
"""
from app.models.user import UserRole
# ── Conjuntos de roles ──────────────────────────────────────────────────────
GLOBAL_ROLES: frozenset[UserRole] = frozenset({
UserRole.ADMIN,
UserRole.SUPPORT_MANAGER,
UserRole.AGENT,
UserRole.AUDITOR,
})
CLIENT_ROLES: frozenset[UserRole] = frozenset({
UserRole.CLIENT_ADMIN,
UserRole.CLIENT_USER,
})
# ── Permisos por rol ────────────────────────────────────────────────────────
ROLE_PERMISSIONS: dict[UserRole, list[str]] = {
# Staff global
UserRole.ADMIN: ["manage_all", "view_all", "audit_all"],
UserRole.SUPPORT_MANAGER: ["manage_teams", "view_all_tickets", "manage_sla"],
UserRole.AGENT: ["view_all_tickets", "update_any_ticket"],
UserRole.AUDITOR: ["view_all", "audit_all"],
# Clientes (acotados al tenant)
UserRole.CLIENT_ADMIN: ["manage_tenant", "manage_tenant_users", "view_tenant_tickets"],
UserRole.CLIENT_USER: ["create_ticket", "view_own_tickets"],
}
# ── Helpers ─────────────────────────────────────────────────────────────────
def is_global_staff(role: UserRole) -> bool:
"""Retorna True si el rol tiene alcance global (staff interno)."""
return role.is_global
def is_client_role(role: UserRole) -> bool:
"""Retorna True si el rol está acotado al tenant del usuario."""
return role.is_client
def has_permission(role: UserRole, permission: str) -> bool:
"""Verifica si un rol tiene un permiso específico."""
return permission in ROLE_PERMISSIONS.get(role, [])

View File

@@ -27,6 +27,24 @@ class UserRole(str, enum.Enum):
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
CLIENT_USER = "CLIENT_USER" # Usuario final cliente
@property
def is_global(self) -> bool:
"""True si el rol tiene alcance global (staff interno cross-tenant)."""
return self in (
UserRole.ADMIN,
UserRole.SUPPORT_MANAGER,
UserRole.AGENT,
UserRole.AUDITOR,
)
@property
def is_client(self) -> bool:
"""True si el rol está acotado al tenant del usuario."""
return self in (
UserRole.CLIENT_ADMIN,
UserRole.CLIENT_USER,
)
class User(Base):
"""Modelo de Usuario."""
@@ -113,11 +131,8 @@ class User(Base):
@property
def is_client(self) -> bool:
"""Check if user is a client."""
return self.role in [
UserRole.CLIENT_ADMIN,
UserRole.CLIENT_USER
]
"""Check if user is a client (rol acotado al propio tenant)."""
return self.role.is_client
@property
def can_manage_users(self) -> bool:
@@ -125,7 +140,7 @@ class User(Base):
return self.role in [
UserRole.ADMIN,
UserRole.SUPPORT_MANAGER,
UserRole.CLIENT_ADMIN
UserRole.CLIENT_ADMIN,
]
@property
@@ -134,7 +149,7 @@ class User(Base):
return self.role in [
UserRole.ADMIN,
UserRole.SUPPORT_MANAGER,
UserRole.AGENT
UserRole.AGENT,
]
@property

View File

@@ -0,0 +1,170 @@
"""
Ticket Service - ServiceManagerWeb
Lógica de negocio para creación y gestión de tickets.
Inyectable vía Depends() en los endpoints de FastAPI.
"""
import uuid
from datetime import datetime
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.category import Category
from app.models.system import System
from app.api.schemas.ticket import TicketCreate
from app.api.v1.helpers import (
generate_next_ticket_number,
calculate_sla_deadlines,
safe_audit_log,
)
class TicketService:
"""Servicio de tickets: encapsula lógica de negocio fuera del router."""
def __init__(self, db: AsyncSession = Depends(get_db)):
self.db = db
async def create_ticket(
self,
ticket: TicketCreate,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict:
"""
Crea un ticket con validación multi-tenant, cálculo de SLA y auto-asignación.
Args:
ticket: Datos del ticket a crear.
tenant_id: Tenant del usuario autenticado.
user_id: ID del usuario que crea el ticket.
Returns:
dict compatible con TicketResponse.
Raises:
HTTPException 400: UUID inválido, categoría/sistema no encontrado o de otro tenant.
HTTPException 500: Fallo persistente tras max_retries.
"""
max_retries = 3
last_error = None
for attempt in range(max_retries):
try:
ticket_number = await generate_next_ticket_number(self.db, tenant_id)
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
category = None
if category_uuid:
category = await self.db.get(Category, category_uuid)
if not category or category.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"La categoría con ID {ticket.category_id} no existe.",
)
if system_uuid:
system = await self.db.get(System, system_uuid)
if not system or system.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"El sistema con ID {ticket.affected_system_id} no existe.",
)
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=tenant_id,
ticket_number=ticket_number,
subject=ticket.subject,
description=ticket.description,
category_id=category_uuid,
affected_system_id=system_uuid,
priority=TicketPriority[ticket.priority.upper()],
created_by=user_id,
assigned_to=assigned_to_user,
status=TicketStatus.NEW,
sla_response_due=sla_response_due,
sla_resolution_due=sla_resolution_due,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
self.db.add(db_ticket)
await self.db.commit()
await self.db.refresh(db_ticket)
await safe_audit_log(
db=self.db,
tenant_id=tenant_id,
user_id=user_id,
action="ticket.create",
resource_type="ticket",
resource_id=db_ticket.id,
new_values={
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"priority": db_ticket.priority.value,
"status": db_ticket.status.value,
},
)
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"title": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"contact_email": ticket.contact_email,
"contact_phone": ticket.contact_phone,
"created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at,
"sla_response_due": db_ticket.sla_response_due,
"sla_resolution_due": db_ticket.sla_resolution_due,
"first_response_at": db_ticket.first_response_at,
"resolved_at": db_ticket.resolved_at,
}
except ValueError as e:
await self.db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}",
)
except HTTPException:
await self.db.rollback()
raise
except Exception as e:
await self.db.rollback()
last_error = e
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
if attempt < max_retries - 1:
continue
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error creating ticket: {str(e)}",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}",
)
def get_ticket_service(db: AsyncSession = Depends(get_db)) -> TicketService:
"""Factory function para inyectar TicketService vía Depends()."""
return TicketService(db)

View File

@@ -0,0 +1,373 @@
"""
Integration Test Fixtures - ServiceManagerWeb (Docker / PostgreSQL)
backend/app/tests/conftest.py
Usa la BD Docker existente (servicemanager).
Los fixtures leen datos reales ya seedeados — no crean ni eliminan nada.
Los tests que inserten datos propios quedan aislados por rollback.
Tenant de referencia : aduanasoft
Usuarios de referencia:
admin@aduanasoft.com → ADMIN
manager@aduanasoft.com → SUPPORT_MANAGER
agente@aduanasoft.com → AGENT
auditor1@test.com → AUDITOR (tenant aduanasoft)
admin-cliente@empresa-demo → CLIENT_ADMIN
test_user@aduanasoft.com → CLIENT_USER
"""
import os
import asyncio
import pytest
from typing import AsyncGenerator, Generator
# ============================================================
# ENV VARS — antes de importar la app
# ============================================================
os.environ.setdefault("ENVIRONMENT", "testing")
os.environ.setdefault("TESTING", "true")
os.environ.setdefault("DEBUG", "false")
os.environ.setdefault("SECRET_KEY", "integration-secret-key-32chars!!!!")
os.environ.setdefault("JWT_SECRET_KEY", "integration-jwt-secret-32chars!!!!")
os.environ.setdefault(
"DATABASE_URL",
"postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager",
)
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/14")
os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/14")
os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/14")
os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000")
os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt")
# ============================================================
# EVENT LOOP (session-scoped)
# ============================================================
@pytest.fixture(scope="session")
def event_loop() -> Generator:
"""Event loop compartido para toda la sesión de tests."""
policy = asyncio.get_event_loop_policy()
loop = policy.new_event_loop()
yield loop
loop.close()
# ============================================================
# ENGINE (session-scoped — reutiliza el pool toda la sesión)
# ============================================================
@pytest.fixture(scope="session")
async def engine():
"""
Conecta al PostgreSQL Docker existente (servicemanager).
NO crea ni destruye el schema — la BD ya está lista.
"""
from sqlalchemy.ext.asyncio import create_async_engine
import app.models # noqa: F401 — registra todos los modelos
_engine = create_async_engine(os.environ["DATABASE_URL"], echo=False)
yield _engine
await _engine.dispose()
# ============================================================
# DB (function-scoped — rollback para datos creados en el test)
# ============================================================
@pytest.fixture
async def db(engine) -> AsyncGenerator:
"""
Sesión con transacción por test.
Los datos seedeados son visibles (ya están committed).
Cualquier INSERT hecho en el test se revierte al finalizar.
"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
await session.begin()
yield session
await session.rollback()
# ============================================================
# TENANT (function-scoped — lee el registro existente)
# ============================================================
@pytest.fixture
async def tenant_a(db):
"""Tenant 'aduanasoft' ya existente en la BD."""
from sqlalchemy import select
from app.models.tenant import Tenant
result = await db.execute(select(Tenant).where(Tenant.slug == "aduanasoft"))
return result.scalar_one()
# ============================================================
# USUARIOS (function-scoped — leen registros existentes)
# ============================================================
@pytest.fixture
async def admin_user(db, tenant_a):
"""ADMIN: admin@aduanasoft.com (tenant aduanasoft)."""
from sqlalchemy import select
from app.models.user import User
result = await db.execute(
select(User)
.where(User.email == "admin@aduanasoft.com")
.where(User.tenant_id == tenant_a.id)
)
return result.scalar_one()
@pytest.fixture
async def manager_user(db, tenant_a):
"""SUPPORT_MANAGER: manager@aduanasoft.com (tenant aduanasoft)."""
from sqlalchemy import select
from app.models.user import User
result = await db.execute(
select(User)
.where(User.email == "manager@aduanasoft.com")
.where(User.tenant_id == tenant_a.id)
)
return result.scalar_one()
@pytest.fixture
async def agent_user(db, tenant_a):
"""AGENT: agente@aduanasoft.com (tenant aduanasoft)."""
from sqlalchemy import select
from app.models.user import User
result = await db.execute(
select(User)
.where(User.email == "agente@aduanasoft.com")
.where(User.tenant_id == tenant_a.id)
)
return result.scalar_one()
@pytest.fixture
async def user_tenant_a(db, tenant_a):
"""CLIENT_USER: test_user@aduanasoft.com (tenant aduanasoft)."""
from sqlalchemy import select
from app.models.user import User
result = await db.execute(
select(User)
.where(User.email == "test_user@aduanasoft.com")
.where(User.tenant_id == tenant_a.id)
)
return result.scalar_one()
# ============================================================
# HTTP CLIENT (function-scoped)
# ============================================================
@pytest.fixture
async def client(db) -> AsyncGenerator:
"""
httpx.AsyncClient contra la app FastAPI en memoria (sin red).
get_db queda sobreescrito para inyectar la sesión de test.
Los cambios del test se revierten al terminar (rollback en db).
"""
import httpx
from httpx import ASGITransport
from app.main import app
from app.core.database import get_db
async def _override_get_db():
yield db
app.dependency_overrides[get_db] = _override_get_db
async with httpx.AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as ac:
yield ac
app.dependency_overrides.pop(get_db, None)
# ============================================================
# FIXTURES DE AISLAMIENTO MULTI-TENANT
# ============================================================
@pytest.fixture
def make_token():
"""Factory de JWT tokens para autenticar clientes HTTP en tests."""
from app.core.security import security
def _make(user):
return security.create_access_token(data={"sub": str(user.id)})
return _make
@pytest.fixture
async def app_with_db(db):
"""
Override de get_db compartido para todos los HTTP clients de un mismo test.
Garantiza que todos los clients usen la misma sesión (y el mismo rollback).
"""
from app.main import app as _app
from app.core.database import get_db
async def _override():
yield db
_app.dependency_overrides[get_db] = _override
yield _app
_app.dependency_overrides.pop(get_db, None)
@pytest.fixture
async def tenant_b(db):
"""Tenant 'empresa-test' creado en la transacción del test (se revierte al final)."""
from app.models.tenant import Tenant
t = Tenant(name="Empresa Test", slug="empresa-test")
db.add(t)
await db.flush()
return t
@pytest.fixture
async def user_b(db, tenant_b):
"""CLIENT_ADMIN en tenant_b — puede gestionar recursos de su tenant."""
from app.models.user import User, UserRole
from app.core.security import security
u = User(
tenant_id=tenant_b.id,
email="admin@empresa-test.com",
first_name="Admin",
last_name="Test",
password_hash=security.hash_password("Test1234!"),
role=UserRole.CLIENT_ADMIN,
is_active=True,
email_verified=True,
)
db.add(u)
await db.flush()
return u
@pytest.fixture
async def client_tenant_a(app_with_db, manager_user, make_token):
"""HTTP client autenticado como SUPPORT_MANAGER de tenant_a (aduanasoft).
Usa manager_user en lugar de admin_user para mantener el aislamiento de
tenant en GET /users/ (el ADMIN global bypasa el filtro de tenant).
"""
import httpx
from httpx import ASGITransport
token = make_token(manager_user)
async with httpx.AsyncClient(
transport=ASGITransport(app=app_with_db),
base_url="http://test",
headers={"Authorization": f"Bearer {token}"},
) as ac:
yield ac
@pytest.fixture
async def client_tenant_b(app_with_db, user_b, make_token):
"""HTTP client autenticado como CLIENT_ADMIN de tenant_b (empresa-test)."""
import httpx
from httpx import ASGITransport
token = make_token(user_b)
async with httpx.AsyncClient(
transport=ASGITransport(app=app_with_db),
base_url="http://test",
headers={"Authorization": f"Bearer {token}"},
) as ac:
yield ac
@pytest.fixture
async def client_admin(app_with_db, admin_user, make_token):
"""HTTP client autenticado como ADMIN global."""
import httpx
from httpx import ASGITransport
token = make_token(admin_user)
async with httpx.AsyncClient(
transport=ASGITransport(app=app_with_db),
base_url="http://test",
headers={"Authorization": f"Bearer {token}"},
) as ac:
yield ac
@pytest.fixture
def create_ticket_tenant_a(client_tenant_a):
"""Factory: crea un ticket en tenant_a vía HTTP y retorna el JSON de respuesta."""
async def _create(subject="Ticket Tenant A", priority="MEDIUM"):
resp = await client_tenant_a.post("/v1/tickets/", json={
"subject": subject,
"description": "Test de aislamiento tenant A",
"priority": priority,
})
assert resp.status_code in (200, 201), f"Error creando ticket A: {resp.text}"
return resp.json()
return _create
@pytest.fixture
def create_ticket_tenant_b(client_tenant_b):
"""Factory: crea un ticket en tenant_b vía HTTP y retorna el JSON de respuesta."""
async def _create(subject="Ticket Tenant B", priority="MEDIUM"):
resp = await client_tenant_b.post("/v1/tickets/", json={
"subject": subject,
"description": "Test de aislamiento tenant B",
"priority": priority,
})
assert resp.status_code in (200, 201), f"Error creando ticket B: {resp.text}"
return resp.json()
return _create
@pytest.fixture
def create_user_tenant_a(client_tenant_a):
"""Factory: crea un usuario en tenant_a vía HTTP y retorna el JSON de respuesta."""
async def _create(email="nuevo_user_a@test.com"):
resp = await client_tenant_a.post("/v1/users/", json={
"email": email,
"first_name": "Usuario",
"last_name": "TenantA",
"password": "Test1234!",
"role": "CLIENT_USER",
})
assert resp.status_code in (200, 201), f"Error creando user A: {resp.text}"
return resp.json()
return _create
@pytest.fixture
def create_user_tenant_b(client_tenant_b):
"""Factory: crea un usuario en tenant_b vía HTTP y retorna el JSON de respuesta."""
async def _create(email="nuevo_user_b@test.com"):
resp = await client_tenant_b.post("/v1/users/", json={
"email": email,
"first_name": "Usuario",
"last_name": "TenantB",
"password": "Test1234!",
"role": "CLIENT_USER",
})
assert resp.status_code in (200, 201), f"Error creando user B: {resp.text}"
return resp.json()
return _create

View File

@@ -0,0 +1,137 @@
"""
Smoke Tests - ServiceManagerWeb
Verifican que el stack completo funciona:
- Conexión a BD Docker
- Fixtures de tenant y usuarios
- Login vía HTTP (httpx + FastAPI en memoria)
- Endpoint protegido con token
"""
import pytest
# ============================================================
# BD + FIXTURES
# ============================================================
@pytest.mark.asyncio
async def test_db_connected(db):
"""La sesión de BD está activa y responde."""
from sqlalchemy import text
result = await db.execute(text("SELECT 1"))
assert result.scalar() == 1
@pytest.mark.asyncio
async def test_tenant_a_existe(tenant_a):
"""El tenant 'aduanasoft' existe y tiene datos válidos."""
assert tenant_a.slug == "aduanasoft"
assert tenant_a.name is not None
@pytest.mark.asyncio
async def test_admin_user_existe(admin_user):
"""El usuario ADMIN existe y pertenece al tenant correcto."""
from app.models.user import UserRole
assert admin_user.email == "admin@aduanasoft.com"
assert admin_user.role == UserRole.ADMIN
assert admin_user.is_active is True
@pytest.mark.asyncio
async def test_manager_user_existe(manager_user):
"""El usuario SUPPORT_MANAGER existe."""
from app.models.user import UserRole
assert manager_user.email == "manager@aduanasoft.com"
assert manager_user.role == UserRole.SUPPORT_MANAGER
@pytest.mark.asyncio
async def test_agent_user_existe(agent_user):
"""El usuario AGENT existe."""
from app.models.user import UserRole
assert agent_user.email == "agente@aduanasoft.com"
assert agent_user.role == UserRole.AGENT
@pytest.mark.asyncio
async def test_client_user_existe(user_tenant_a):
"""El CLIENT_USER existe."""
from app.models.user import UserRole
assert user_tenant_a.email == "test_user@aduanasoft.com"
assert user_tenant_a.role == UserRole.CLIENT_USER
# ============================================================
# HTTP — LOGIN
# ============================================================
@pytest.mark.asyncio
async def test_login_admin_ok(client):
"""Login con credenciales de admin devuelve access_token."""
response = await client.post(
"/v1/auth/login",
json={
"email": "admin@aduanasoft.com",
"password": "admin123",
"tenant_slug": "aduanasoft",
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_login_credenciales_invalidas(client):
"""Login con contraseña incorrecta devuelve 401."""
response = await client.post(
"/v1/auth/login",
json={
"email": "admin@aduanasoft.com",
"password": "wrongpassword",
"tenant_slug": "aduanasoft",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_endpoint_sin_token_devuelve_401(client):
"""Acceder a un endpoint protegido sin token devuelve 401."""
response = await client.get(
"/v1/users/me",
headers={"X-Tenant-Slug": "aduanasoft"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_y_me(client):
"""Login exitoso → /users/me devuelve el usuario correcto."""
# Login
login = await client.post(
"/v1/auth/login",
json={
"email": "admin@aduanasoft.com",
"password": "admin123",
"tenant_slug": "aduanasoft",
},
)
assert login.status_code == 200
token = login.json()["access_token"]
# Endpoint protegido
me = await client.get(
"/v1/users/me",
headers={
"Authorization": f"Bearer {token}",
"X-Tenant-Slug": "aduanasoft",
},
)
assert me.status_code == 200
data = me.json()
assert data["email"] == "admin@aduanasoft.com"
assert data["role"] == "ADMIN"

View File

@@ -0,0 +1,123 @@
"""
Pruebas de aislamiento multi-tenant para tickets y usuarios.
Usa solo los fixtures definidos en conftest.py.
Roles en juego:
client_tenant_a → SUPPORT_MANAGER (aduanasoft) — restringido a su tenant
client_tenant_b → CLIENT_ADMIN (empresa-test) — restringido a su tenant
client_admin → ADMIN global (aduanasoft) — acceso a todos los tenants
"""
import pytest
@pytest.mark.asyncio
async def test_tenant_a_cannot_see_tenant_b_tickets(
client_tenant_a, create_ticket_tenant_b
):
"""
El usuario del tenant B crea un ticket.
El usuario del tenant A (SUPPORT_MANAGER) lista sus tickets.
El ticket de tenant B NO debe aparecer en la respuesta.
"""
# El usuario del tenant B crea un ticket
ticket_b = await create_ticket_tenant_b()
ticket_b_id = ticket_b["id"]
# El usuario del tenant A lista sus tickets
response = await client_tenant_a.get("/v1/tickets/")
assert response.status_code == 200
ids_visibles = {t["id"] for t in response.json()}
# El ticket de tenant B no debe ser visible para tenant A
assert ticket_b_id not in ids_visibles, (
f"Fallo de aislamiento: ticket de tenant B ({ticket_b_id}) "
f"visible para usuario de tenant A"
)
@pytest.mark.asyncio
async def test_tenant_b_cannot_edit_tenant_a_ticket(
create_ticket_tenant_a, client_tenant_b
):
"""
El usuario del tenant A crea un ticket.
El usuario del tenant B intenta editar ese ticket vía PATCH.
Debe recibir 403 (prohibido) o 404 (no encontrado).
"""
# El usuario del tenant A crea un ticket
ticket_a = await create_ticket_tenant_a()
ticket_a_id = ticket_a["id"]
# El usuario del tenant B intenta editar el ticket de tenant A
response = await client_tenant_b.patch(
f"/v1/tickets/{ticket_a_id}",
json={"status": "CLOSED"},
)
# Debe recibir 403 o 404 — nunca 200
assert response.status_code in (403, 404), (
f"Fallo de aislamiento: tenant B pudo editar ticket de tenant A "
f"(HTTP {response.status_code})"
)
@pytest.mark.asyncio
async def test_tenant_a_cannot_see_tenant_b_users(
client_tenant_a, create_user_tenant_b
):
"""
El usuario del tenant B crea un usuario nuevo.
El usuario del tenant A (SUPPORT_MANAGER) lista los usuarios.
El usuario de tenant B NO debe aparecer en la respuesta.
"""
# El usuario del tenant B crea un usuario
user_b = await create_user_tenant_b()
user_b_id = user_b["id"]
# El usuario del tenant A lista los usuarios de su tenant
response = await client_tenant_a.get("/v1/users/")
assert response.status_code == 200
ids_visibles = {u["id"] for u in response.json()}
# El usuario de tenant B no debe ser visible para tenant A
assert user_b_id not in ids_visibles, (
f"Fallo de aislamiento: usuario de tenant B ({user_b_id}) "
f"visible para usuario de tenant A"
)
@pytest.mark.asyncio
async def test_admin_sees_all_tenant_data(
client_admin,
create_ticket_tenant_a,
create_ticket_tenant_b,
create_user_tenant_a,
create_user_tenant_b,
):
"""
El ADMIN global debe poder ver tickets y usuarios de TODOS los tenants.
- Tickets: vía /v1/tickets/admin/all (endpoint multi-tenant).
- Usuarios: vía /v1/users/ (ADMIN bypasa el filtro de tenant).
"""
# Crear datos en ambos tenants
ticket_a = await create_ticket_tenant_a()
ticket_b = await create_ticket_tenant_b()
user_a = await create_user_tenant_a()
user_b = await create_user_tenant_b()
# El admin lista todos los tickets (endpoint multi-tenant)
resp_tickets = await client_admin.get("/v1/tickets/admin/all")
assert resp_tickets.status_code == 200
ids_tickets = {t["id"] for t in resp_tickets.json()}
assert ticket_a["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant A"
assert ticket_b["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant B"
# El admin lista todos los usuarios (ADMIN bypasa filtro de tenant)
resp_users = await client_admin.get("/v1/users/")
assert resp_users.status_code == 200
ids_users = {u["id"] for u in resp_users.json()}
assert user_a["id"] in ids_users, "El ADMIN no ve el usuario de tenant A"
assert user_b["id"] in ids_users, "El ADMIN no ve el usuario de tenant B"

104
backend/auth_backup.ts Normal file
View File

@@ -0,0 +1,104 @@
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
export interface User {
id: string;
email: string;
first_name: string;
last_name: string;
tenant_id: string;
role: 'CLIENT_ADMIN' | 'CLIENT_USER';
is_active: boolean;
is_two_factor_enabled: boolean;
created_at: string;
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
export interface LoginRequest {
email: string;
password: string;
tenant_slug: string;
totp_code?: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
user: User;
}
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
isLoading: false
};
function createAuthStore() {
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
let _state = initialState;
subscribe(s => { _state = s; });
return {
subscribe,
init: async () => {
if (typeof window !== 'undefined') {
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'client', 'X-Tenant-Slug': 'aduanasoft' }
});
if (response.ok) {
const user = await response.json();
set({ user, token: null, isAuthenticated: true, isLoading: false });
}
} catch (error) {}
}
},
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Tenant-Slug': credentials.tenant_slug,
},
body: JSON.stringify(credentials)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Login failed');
}
const data: LoginResponse = await response.json();
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
logout: async () => {
try {
const token = _state.token;
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: {
'X-App': 'client',
'X-Tenant-Slug': 'aduanasoft',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
} catch {}
set(initialState);
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
},
updateUser: (user: User) => { update(state => ({ ...state, user })); },
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
};
}
export const auth = createAuthStore();

6
backend/check_lines.py Normal file
View File

@@ -0,0 +1,6 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
lines = f.readlines()
for i, line in enumerate(lines):
if "tenant_id == current_user.tenant_id" in line:
print(f"Linea {i+1}: {line.rstrip()}")

14
backend/check_user.py Normal file
View File

@@ -0,0 +1,14 @@
import asyncio
from app.core.database import AsyncSessionLocal
from app.models.user import User
from sqlalchemy import select
import uuid
async def check():
async with AsyncSessionLocal() as db:
result = await db.execute(select(User))
users = result.scalars().all()
for u in users:
print(f"ID: {u.id} | Email: {u.email} | Tenant: {u.tenant_id} | Rol: {u.role}")
asyncio.run(check())

16
backend/fix_response.py Normal file
View File

@@ -0,0 +1,16 @@
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
lines = f.readlines()
# Linea 286 (0-indexed 285): "tenant_id": str(user.tenant_id),
# Agregar tenant_slug despues de tenant_id
for i, line in enumerate(lines):
if '"tenant_id": str(user.tenant_id),' in line:
indent = " "
new_line = indent + '"tenant_slug": tenant.slug if tenant else str(user.tenant_id),\n'
lines.insert(i + 1, new_line)
print(f"OK: tenant_slug agregado en linea {i+2}")
break
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.writelines(lines)
print("Listo")

18
backend/fix_syntax.py Normal file
View File

@@ -0,0 +1,18 @@
with open("/app/app/api/v1/endpoints/users.py") as f:
lines = f.readlines()
new_block = [
" if current_user.role.value == 'ADMIN':\n",
" query = select(User).where(User.id == user_id)\n",
" else:\n",
" query = select(User).where(\n",
" User.id == user_id,\n",
" User.tenant_id == current_user.tenant_id\n",
" )\n",
]
lines[150:161] = new_block
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.writelines(lines)
print("Listo")

28
backend/fix_users.py Normal file
View File

@@ -0,0 +1,28 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
content = f.read()
old1 = " User.tenant_id == current_user.tenant_id # " + "\u2705" + " Seguridad multi-tenant"
new1 = """ from app.models.user import UserRole as _UserRole
if current_user.role == _UserRole.ADMIN:
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)"""
if old1 in content:
content = content.replace(old1, new1)
print("OK bloque 1")
else:
print("SKIP bloque 1 - buscando alternativa")
old1b = " User.tenant_id == current_user.tenant_id\n )\n result = await db.execute(query)\n if not user:"
new1b = " User.tenant_id == current_user.tenant_id\n )\n result = await db.execute(query)\n if not user:"
print("Lineas con tenant_id encontradas:")
for i, line in enumerate(content.split("\n")):
if "tenant_id == current_user.tenant_id" in line:
print(f" Linea {i}: {line}")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.write(content)
print("Listo")

60
backend/fix_users2.py Normal file
View File

@@ -0,0 +1,60 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
content = f.read()
from app.models.user import UserRole as _UserRole
# Reemplazar el patron comun de query con filtro de tenant
# por una version que permite a ADMIN ver todos los tenants
old_get_user = """ query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
user = result.scalar_one_or_none()
if not user:"""
new_get_user = """ if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
user = result.scalar_one_or_none()
if not user:"""
old_update_user = """ query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
if not db_user:"""
new_update_user = """ if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
if not db_user:"""
count = 0
for old, new in [(old_get_user, new_get_user), (old_update_user, new_update_user)]:
occurrences = content.count(old)
if occurrences > 0:
content = content.replace(old, new)
count += occurrences
print(f"OK: {occurrences} ocurrencia(s) reemplazada(s)")
else:
print(f"SKIP: bloque no encontrado")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.write(content)
print(f"Total: {count} reemplazos aplicados")

36
backend/fix_users3.py Normal file
View File

@@ -0,0 +1,36 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
content = f.read()
old1 = """ # Buscar usuario
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
if not db_user:"""
new1 = """ # Buscar usuario - ADMIN global puede editar cualquier tenant
if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
if not db_user:"""
count = content.count(old1)
if count > 0:
content = content.replace(old1, new1)
print(f"OK: {count} bloques reemplazados")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.write(content)
print("Listo")

38
backend/fix_users4.py Normal file
View File

@@ -0,0 +1,38 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
lines = f.readlines()
admin_check = [
" # Buscar usuario - ADMIN global puede editar cualquier tenant\n",
" if current_user.role.value == \"ADMIN\":\n",
" query = select(User).where(User.id == user_id)\n",
" else:\n",
" query = select(User).where(\n",
" User.id == user_id,\n",
" User.tenant_id == current_user.tenant_id\n",
" )\n",
]
# Reemplazar bloques en lineas 198, 305, 382 (0-indexed: 197, 304, 381)
replaced = 0
new_lines = lines[:]
i = 0
while i < len(new_lines):
if (new_lines[i].strip() == "# Buscar usuario" and
i+1 < len(new_lines) and "select(User).where(" in new_lines[i+1] and
i+2 < len(new_lines) and "User.id == user_id," in new_lines[i+2] and
i+3 < len(new_lines) and "User.tenant_id == current_user.tenant_id" in new_lines[i+3]):
indent = " "
new_block = admin_check[:]
# Remove old 4 lines of query block (comment + query 4 lines)
new_lines[i:i+5] = new_block
replaced += 1
i += len(new_block)
else:
i += 1
print(f"Reemplazos realizados: {replaced}")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.writelines(new_lines)
print("Listo")

View File

@@ -0,0 +1,35 @@
"""Add CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR to user_role_enum
Revision ID: c1d2e3f4a5b6
Revises: b7c8d9e0f1a2
Create Date: 2026-03-03 10:00:00.000000
Agrega tres nuevos roles de cliente al enum PostgreSQL:
- CLIENT_MANAGER → gestiona tickets y usuarios del tenant
- CLIENT_AGENT → atiende tickets del tenant
- CLIENT_AUDITOR → auditoría de solo lectura del tenant
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = 'c1d2e3f4a5b6'
down_revision = 'b7c8d9e0f1a2'
branch_labels = None
depends_on = None
def upgrade() -> None:
# PostgreSQL permite agregar valores a un enum con ADD VALUE.
# IF NOT EXISTS evita error si la migración se aplica dos veces.
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_MANAGER'")
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AGENT'")
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AUDITOR'")
def downgrade() -> None:
# PostgreSQL no permite eliminar valores de un enum con ALTER TYPE DROP VALUE.
# Para revertir habría que recrear el tipo completo desde cero, lo que requiere
# actualizar todas las columnas que lo usan. Se documenta como no reversible
# automáticamente — usar con precaución.
pass

View File

@@ -0,0 +1,92 @@
"""Remove CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR from user_role_enum
Revision ID: d2e3f4a5b6c7
Revises: c1d2e3f4a5b6
Create Date: 2026-03-03 14:00:00.000000
Consolida 9 roles → 6 roles migrando datos primero y luego recreando
el tipo enum de PostgreSQL (única forma de eliminar valores en PG).
Mapeo de datos:
CLIENT_MANAGER → CLIENT_ADMIN (conserva nivel de gestión)
CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
ADVERTENCIA DOWNGRADE: La migración inversa restaura los valores del
enum pero NO puede recuperar la distinción original entre CLIENT_AGENT
y CLIENT_AUDITOR (ambos quedaron como CLIENT_USER). El downgrade es
seguro a nivel de integridad de datos, pero irreversible en semántica.
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = 'd2e3f4a5b6c7'
down_revision = 'c1d2e3f4a5b6'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── Paso 1: Migrar datos ANTES de modificar el tipo ────────────────────
# CLIENT_MANAGER → CLIENT_ADMIN (conserva acceso de gestión)
op.execute("UPDATE users SET role = 'CLIENT_ADMIN' WHERE role = 'CLIENT_MANAGER'")
# CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AGENT'")
# CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AUDITOR'")
# ── Paso 2: Soltar la restricción de tipo para poder recrear el enum ───
# PostgreSQL no permite DROP VALUE en un enum; hay que recrear el tipo.
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
# ── Paso 3: Eliminar tipo actual y recrearlo solo con los 6 roles ──────
op.execute("DROP TYPE user_role_enum")
op.execute("""
CREATE TYPE user_role_enum AS ENUM (
'ADMIN',
'SUPPORT_MANAGER',
'AGENT',
'AUDITOR',
'CLIENT_ADMIN',
'CLIENT_USER'
)
""")
# ── Paso 4: Restaurar columna al tipo enum ──────────────────────────────
op.execute(
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
"USING role::user_role_enum"
)
def downgrade() -> None:
# ── Paso 1: Soltar la restricción de tipo para recrear el enum ─────────
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
# ── Paso 2: Recrear enum con los 9 valores originales ──────────────────
op.execute("DROP TYPE user_role_enum")
op.execute("""
CREATE TYPE user_role_enum AS ENUM (
'ADMIN',
'SUPPORT_MANAGER',
'AGENT',
'AUDITOR',
'CLIENT_ADMIN',
'CLIENT_MANAGER',
'CLIENT_AGENT',
'CLIENT_AUDITOR',
'CLIENT_USER'
)
""")
# ── Paso 3: Restaurar columna al tipo enum ──────────────────────────────
op.execute(
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
"USING role::user_role_enum"
)
# ── Nota sobre pérdida de datos ─────────────────────────────────────────
# Los usuarios que eran CLIENT_MANAGER ahora son CLIENT_ADMIN.
# Los usuarios que eran CLIENT_AGENT o CLIENT_AUDITOR ahora son CLIENT_USER.
# No es posible restaurar la distinción original automáticamente.

11
backend/show_context.py Normal file
View File

@@ -0,0 +1,11 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
lines = f.readlines()
# Mostrar contexto alrededor de lineas con tenant_id
targets = [51, 92, 159, 200, 307, 384]
for t in targets:
print(f"\n=== Linea {t} ===")
start = max(0, t-5)
end = min(len(lines), t+5)
for i in range(start, end):
print(f"{i+1}: {lines[i].rstrip()}")

6
check_lines.py Normal file
View File

@@ -0,0 +1,6 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
lines = f.readlines()
for i, line in enumerate(lines):
if "tenant_id == current_user.tenant_id" in line:
print(f"Linea {i+1}: {line.rstrip()}")

14
check_user.py Normal file
View File

@@ -0,0 +1,14 @@
import asyncio
from app.core.database import AsyncSessionLocal
from app.models.user import User
from sqlalchemy import select
import uuid
async def check():
async with AsyncSessionLocal() as db:
result = await db.execute(select(User))
users = result.scalars().all()
for u in users:
print(f"ID: {u.id} | Email: {u.email} | Tenant: {u.tenant_id} | Rol: {u.role}")
asyncio.run(check())

View File

@@ -13,9 +13,9 @@ services:
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
#- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
ports:
- "5432:5432"
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"]
interval: 10s
@@ -215,7 +215,7 @@ services:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- uploads_data:/var/www/uploads:ro
ports:
- "80:80"
- "8088:80"
depends_on:
- backend
- frontend-client

67
fix_login.py Normal file
View File

@@ -0,0 +1,67 @@
import re
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # 1. Validar tenant
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
logger.warning(
"Login failed - tenant not found",
email=login_data.email,
tenant_slug=login_data.tenant_slug,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)'''
new = ''' # 1. Validar tenant - por slug si viene, sino detectar por email
if login_data.tenant_slug:
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
else:
tenant = None'''
if old in content:
content = content.replace(old, new)
print("OK: bloque tenant reemplazado")
else:
print("ERROR: bloque no encontrado")
# Tambien actualizar la query de usuario para usar tenant o no
old2 = ''' # 2. Buscar usuario en base de datos (aislado por tenant)
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)'''
new2 = ''' # 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
if tenant:
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
else:
query = select(User).where(User.email == login_data.email)'''
if old2 in content:
content = content.replace(old2, new2)
print("OK: bloque query reemplazado")
else:
print("ERROR: bloque query no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)
print("Listo")

23
fix_ratelimit.py Normal file
View File

@@ -0,0 +1,23 @@
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)'''
new = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)'''
if old in content:
content = content.replace(old, new)
print("OK: rate limiting fix aplicado")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)

16
fix_response.py Normal file
View File

@@ -0,0 +1,16 @@
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
lines = f.readlines()
# Linea 286 (0-indexed 285): "tenant_id": str(user.tenant_id),
# Agregar tenant_slug despues de tenant_id
for i, line in enumerate(lines):
if '"tenant_id": str(user.tenant_id),' in line:
indent = " "
new_line = indent + '"tenant_slug": tenant.slug if tenant else str(user.tenant_id),\n'
lines.insert(i + 1, new_line)
print(f"OK: tenant_slug agregado en linea {i+2}")
break
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.writelines(lines)
print("Listo")

18
fix_syntax.py Normal file
View File

@@ -0,0 +1,18 @@
with open("/app/app/api/v1/endpoints/users.py") as f:
lines = f.readlines()
new_block = [
" if current_user.role.value == 'ADMIN':\n",
" query = select(User).where(User.id == user_id)\n",
" else:\n",
" query = select(User).where(\n",
" User.id == user_id,\n",
" User.tenant_id == current_user.tenant_id\n",
" )\n",
]
lines[150:161] = new_block
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.writelines(lines)
print("Listo")

28
fix_users.py Normal file
View File

@@ -0,0 +1,28 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
content = f.read()
old1 = " User.tenant_id == current_user.tenant_id # " + "\u2705" + " Seguridad multi-tenant"
new1 = """ from app.models.user import UserRole as _UserRole
if current_user.role == _UserRole.ADMIN:
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)"""
if old1 in content:
content = content.replace(old1, new1)
print("OK bloque 1")
else:
print("SKIP bloque 1 - buscando alternativa")
old1b = " User.tenant_id == current_user.tenant_id\n )\n result = await db.execute(query)\n if not user:"
new1b = " User.tenant_id == current_user.tenant_id\n )\n result = await db.execute(query)\n if not user:"
print("Lineas con tenant_id encontradas:")
for i, line in enumerate(content.split("\n")):
if "tenant_id == current_user.tenant_id" in line:
print(f" Linea {i}: {line}")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.write(content)
print("Listo")

60
fix_users2.py Normal file
View File

@@ -0,0 +1,60 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
content = f.read()
from app.models.user import UserRole as _UserRole
# Reemplazar el patron comun de query con filtro de tenant
# por una version que permite a ADMIN ver todos los tenants
old_get_user = """ query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
user = result.scalar_one_or_none()
if not user:"""
new_get_user = """ if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
user = result.scalar_one_or_none()
if not user:"""
old_update_user = """ query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
if not db_user:"""
new_update_user = """ if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
if not db_user:"""
count = 0
for old, new in [(old_get_user, new_get_user), (old_update_user, new_update_user)]:
occurrences = content.count(old)
if occurrences > 0:
content = content.replace(old, new)
count += occurrences
print(f"OK: {occurrences} ocurrencia(s) reemplazada(s)")
else:
print(f"SKIP: bloque no encontrado")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.write(content)
print(f"Total: {count} reemplazos aplicados")

36
fix_users3.py Normal file
View File

@@ -0,0 +1,36 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
content = f.read()
old1 = """ # Buscar usuario
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
if not db_user:"""
new1 = """ # Buscar usuario - ADMIN global puede editar cualquier tenant
if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
if not db_user:"""
count = content.count(old1)
if count > 0:
content = content.replace(old1, new1)
print(f"OK: {count} bloques reemplazados")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.write(content)
print("Listo")

38
fix_users4.py Normal file
View File

@@ -0,0 +1,38 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
lines = f.readlines()
admin_check = [
" # Buscar usuario - ADMIN global puede editar cualquier tenant\n",
" if current_user.role.value == \"ADMIN\":\n",
" query = select(User).where(User.id == user_id)\n",
" else:\n",
" query = select(User).where(\n",
" User.id == user_id,\n",
" User.tenant_id == current_user.tenant_id\n",
" )\n",
]
# Reemplazar bloques en lineas 198, 305, 382 (0-indexed: 197, 304, 381)
replaced = 0
new_lines = lines[:]
i = 0
while i < len(new_lines):
if (new_lines[i].strip() == "# Buscar usuario" and
i+1 < len(new_lines) and "select(User).where(" in new_lines[i+1] and
i+2 < len(new_lines) and "User.id == user_id," in new_lines[i+2] and
i+3 < len(new_lines) and "User.tenant_id == current_user.tenant_id" in new_lines[i+3]):
indent = " "
new_block = admin_check[:]
# Remove old 4 lines of query block (comment + query 4 lines)
new_lines[i:i+5] = new_block
replaced += 1
i += len(new_block)
else:
i += 1
print(f"Reemplazos realizados: {replaced}")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.writelines(new_lines)
print("Listo")

View File

@@ -1,19 +1,37 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const cookie = event.request.headers.get('cookie') ?? '';
if (cookie) {
try {
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
const response = await fetch(`${apiUrl}/v1/auth/me`, {
headers: { cookie, 'X-App': 'client' }
});
event.locals.user = response.ok ? await response.json() : null;
} catch {
event.locals.user = null;
}
} else {
event.locals.user = null;
}
return resolve(event);
};
// No restaurar sesión en la página de login
if (event.url.pathname === '/login') {
event.locals.user = null;
return resolve(event);
}
const cookieHeader = event.request.headers.get('cookie') ?? '';
const cookieMatch = cookieHeader.match(/(?:client_access_token|internal_access_token)=([^;]+)/);
const token = cookieMatch?.[1];
if (token) {
try {
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
const response = await fetch(`${apiUrl}/v1/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`,
'X-App': 'client',
'X-Tenant-Slug': 'aduanasoft'
}
});
if (response.ok) {
event.locals.user = await response.json();
} else {
event.locals.user = null;
event.cookies.delete('client_access_token', { path: '/' });
event.cookies.delete('internal_access_token', { path: '/' });
}
} catch {
event.locals.user = null;
}
} else {
event.locals.user = null;
}
return resolve(event);
};

View File

@@ -1,7 +1,5 @@
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
// Types
export interface User {
id: string;
email: string;
@@ -13,131 +11,94 @@ export interface User {
is_two_factor_enabled: boolean;
created_at: string;
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
export interface LoginRequest {
email: string;
password: string;
tenant_slug: string;
totp_code?: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
user: User;
}
// Initial state
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
isLoading: false
};
// Create auth store
function createAuthStore() {
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
let _state = initialState;
subscribe(s => { _state = s; });
return {
subscribe,
// Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
init: async () => {
if (typeof window !== 'undefined') {
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'client' }
headers: { 'X-App': 'client', 'X-Tenant-Slug': 'aduanasoft' }
});
if (response.ok) {
const user = await response.json();
set({
user,
token: null,
isAuthenticated: true,
isLoading: false
});
set({ user, token: null, isAuthenticated: true, isLoading: false });
}
// 401/400 es esperado cuando no hay sesión activa — no es un error
} catch (error) {
// Ignorar errores de red en init
}
} catch (error) {}
}
},
// Login
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Tenant-Slug': credentials.tenant_slug,
},
body: JSON.stringify(credentials)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Login failed');
}
const data: LoginResponse = await response.json();
set({
user: data.user,
token: data.access_token,
isAuthenticated: true,
isLoading: false
});
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
// Logout
logout: async () => {
// Llamar al backend para que borre la cookie HttpOnly
try {
const token = _state.token;
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: { 'X-App': 'client' }
headers: {
'X-App': 'client',
'X-Tenant-Slug': 'aduanasoft',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
} catch { /* ignorar errores de red */ }
} catch {}
set(initialState);
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
},
// Update user data
updateUser: (user: User) => {
update(state => ({ ...state, user }));
},
// Set user from SSR pre-load (no fetch required)
setUser: (user: User) => {
set({ user, token: null, isAuthenticated: true, isLoading: false });
},
// Set loading state
setLoading: (isLoading: boolean) => {
update(state => ({ ...state, isLoading }));
}
updateUser: (user: User) => { update(state => ({ ...state, user })); },
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
};
}
export const auth = createAuthStore();

View File

@@ -0,0 +1,104 @@
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
export interface User {
id: string;
email: string;
first_name: string;
last_name: string;
tenant_id: string;
role: 'CLIENT_ADMIN' | 'CLIENT_USER';
is_active: boolean;
is_two_factor_enabled: boolean;
created_at: string;
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
export interface LoginRequest {
email: string;
password: string;
tenant_slug: string;
totp_code?: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
user: User;
}
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
isLoading: false
};
function createAuthStore() {
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
let _state = initialState;
subscribe(s => { _state = s; });
return {
subscribe,
init: async () => {
if (typeof window !== 'undefined') {
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'client', 'X-Tenant-Slug': 'aduanasoft' }
});
if (response.ok) {
const user = await response.json();
set({ user, token: null, isAuthenticated: true, isLoading: false });
}
} catch (error) {}
}
},
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Tenant-Slug': credentials.tenant_slug,
},
body: JSON.stringify(credentials)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Login failed');
}
const data: LoginResponse = await response.json();
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
logout: async () => {
try {
const token = _state.token;
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: {
'X-App': 'client',
'X-Tenant-Slug': 'aduanasoft',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
} catch {}
set(initialState);
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
},
updateUser: (user: User) => { update(state => ({ ...state, user })); },
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
};
}
export const auth = createAuthStore();

View File

@@ -1,13 +1,7 @@
/**
* Cliente HTTP centralizado para frontend-client.
* Usa cookies HttpOnly (client_access_token) como fuente primaria de auth,
* con Bearer token como complemento cuando está disponible en memoria.
*/
import { auth } from '$lib/stores/auth';
import { get } from 'svelte/store';
const API_BASE = '/api/v1';
interface RequestOptions extends RequestInit {
params?: Record<string, string>;
}
@@ -20,7 +14,6 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
const filteredParams = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
if (Object.keys(filteredParams).length > 0) {
url += `?${new URLSearchParams(filteredParams).toString()}`;
}
@@ -29,7 +22,6 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
const authState = get(auth);
const headers = new Headers(init.headers);
// Bearer header cuando el token está en memoria (sesión activa sin reload)
if (authState.token) {
headers.set('Authorization', `Bearer ${authState.token}`);
}
@@ -39,8 +31,9 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
// Identifica este frontend para que el backend use client_access_token
headers.set('X-App', 'client');
const slug = get(authStore)?.user?.tenant_slug || get(authStore)?.user?.tenant_id || '';
headers.set('X-Tenant-Slug', slug);
const response = await fetch(url, {
...init,
@@ -78,6 +71,8 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
headers.set('X-Tenant-ID', authState.user.tenant_id);
}
headers.set('X-App', 'client');
const slug = get(authStore)?.user?.tenant_slug || get(authStore)?.user?.tenant_id || '';
headers.set('X-Tenant-Slug', slug);
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'GET',
@@ -108,19 +103,14 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
export const api = {
get: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }),
post: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }),
put: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }),
patch: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }),
downloadFile: (endpoint: string, filename: string) =>
downloadFile(endpoint, filename)
};
};

View File

@@ -7,7 +7,7 @@
let email = '';
let password = '';
let tenantSlug = 'aduanasoft-demo';
let tenantSlug = 'ventas';
let totpCode = '';
let isLoading = false;
let showTwoFactor = false;
@@ -34,11 +34,11 @@
await auth.login({
email,
password,
tenant_slug: tenantSlug.trim() || 'aduanasoft-demo',
tenant_slug: tenantSlug.trim() || 'ventas',
totp_code: totpCode || undefined
});
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
goto('/');
} catch (error: any) {
console.error('Login error:', error);
@@ -46,9 +46,9 @@
// Check if 2FA is required
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
showTwoFactor = true;
errorMessage = 'Introduce el código de tu aplicación de autenticación';
errorMessage = 'Introduce el código de tu aplicación de autenticación';
} else {
errorMessage = error.message || 'Error al iniciar sesión';
errorMessage = error.message || 'Error al iniciar sesión';
toast.error(errorMessage);
}
} finally {
@@ -96,8 +96,8 @@
de Servicios de TI
</h2>
<p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md">
Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y
reciba asistencia especializada para garantizar la continuidad de su operación.
Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y
reciba asistencia especializada para garantizar la continuidad de su operación.
</p>
</div>
@@ -125,7 +125,7 @@
<div
class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600"
>
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
<Icon name="alert-circle" className="w-4 h-4 flex-shrink-0" />
{errorMessage}
</div>
{/if}
@@ -135,13 +135,13 @@
<!-- Email Input -->
<div class="space-y-1.5">
<label for="email" class="block text-sm font-semibold text-gray-700"
>Correo Electrónico</label
>Correo Electrónico</label
>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon
name="mail"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div>
<input
@@ -160,13 +160,13 @@
<!-- Password Input -->
<div class="space-y-1.5">
<label for="password" class="block text-sm font-semibold text-gray-700"
>Contraseña</label
>Contraseña</label
>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon
name="lock"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div>
{#if showPassword}
@@ -176,7 +176,7 @@
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
placeholder="••••••••"
required
disabled={isLoading}
/>
@@ -187,7 +187,7 @@
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
placeholder="••••••••"
required
disabled={isLoading}
/>
@@ -197,7 +197,7 @@
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
on:click={() => (showPassword = !showPassword)}
>
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
<Icon name={showPassword ? 'eye-off' : 'eye'} className="w-5 h-5" />
</button>
</div>
</div>
@@ -221,7 +221,7 @@
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
on:click={() => goto('/forgot-password')}
>
Olvide mi clave
Olvidé mi clave
</button>
</div>
</div>
@@ -229,13 +229,13 @@
<!-- 2FA Input -->
<div class="space-y-4 animate-slide-up">
<label for="code" class="block text-sm font-medium text-gray-700 text-center"
>Código de Verificación (2FA)</label
>Código de Verificación (2FA)</label
>
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p>
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="shield-check" class="w-5 h-5 text-blue-500" />
<Icon name="shield-check" className="w-5 h-5 text-blue-500" />
</div>
<input
id="code"
@@ -259,7 +259,7 @@
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" />
<Icon name="loader-2" className="w-5 h-5 animate-spin mr-2" />
Procesando...
{:else}
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
@@ -268,7 +268,7 @@
</div>
<div class="mt-8 text-center text-xs text-gray-400">
© 2026 Aduanasoft. Acceso exclusivo autorizado.
© 2026 Aduanasoft. Acceso exclusivo autorizado.
</div>
</form>
</div>

View File

@@ -1,38 +1,35 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 3000,
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
// HMR: el browser llega al contenedor en el mismo puerto 3000
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
preview: {
port: 3000,
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
});
plugins: [sveltekit()],
server: {
port: 3000,
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
},
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
preview: {
port: 3000,
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
});

View File

@@ -1,23 +1,22 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
// Types
export interface InternalUser {
export interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: 'ADMIN' | 'SUPPORT_MANAGER' | 'AGENT' | 'AUDITOR';
tenant_id: string;
tenant_slug: string;
role: 'CLIENT_ADMIN' | 'CLIENT_USER';
is_active: boolean;
is_two_factor_enabled: boolean;
created_at: string;
tenant_id: string;
}
export interface AuthState {
user: InternalUser | null;
user: User | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
@@ -25,180 +24,90 @@ export interface AuthState {
export interface LoginRequest {
email: string;
password: string;
tenant_slug: string;
tenant_slug?: string;
totp_code?: string;
}
export interface LoginResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
user: InternalUser;
user: User;
}
export interface RefreshTokenRequest {
refresh_token: string;
}
export interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
// Initial state
const initialState: AuthState = {
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false
};
// Create auth store
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>(initialState);
// Track current state for uso interno (evita dependencias circulares)
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
let _state = initialState;
subscribe(s => { _state = s; });
return {
subscribe,
// Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
init: async () => {
if (typeof window !== 'undefined') {
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'internal' }
headers: { 'X-App': 'client' }
});
if (response.ok) {
const user = await response.json();
set({
user,
token: null,
refreshToken: null,
isAuthenticated: true,
isLoading: false
});
set({ user, token: null, isAuthenticated: true, isLoading: false });
}
// 401/400 es esperado cuando no hay sesión activa — no es un error
} catch (error) {
// Ignorar errores de red en init
}
} catch (error) {}
}
},
// Login
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-App': 'client',
},
body: JSON.stringify(credentials)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Login failed');
}
const data: LoginResponse = await response.json();
set({
user: data.user,
token: data.access_token,
refreshToken: data.refresh_token,
isAuthenticated: true,
isLoading: false
});
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
// Refresh Session
refreshSession: async (): Promise<void> => {
const currentRefreshToken = _state.refreshToken;
if (!currentRefreshToken) {
throw new Error("No refresh token available");
}
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: currentRefreshToken })
});
if (!response.ok) {
// If refresh fails, logout
if (response.status === 401 || response.status === 403) {
auth.logout();
}
const error = await response.json();
throw new Error(error.detail || 'Refresh failed');
}
const data: TokenResponse = await response.json();
update(state => ({
...state,
token: data.access_token,
isLoading: false
}));
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
// Logout
logout: async () => {
// Llamar al backend para que borre la cookie HttpOnly
try {
const token = _state.token;
const slug = _state.user?.tenant_slug || _state.user?.tenant_id || '';
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: { 'X-App': 'internal' }
headers: {
'X-App': 'client',
'X-Tenant-Slug': slug,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
} catch { /* ignorar errores de red */ }
} catch {}
set(initialState);
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
},
// Update user data
updateUser: (user: InternalUser) => {
update(state => ({ ...state, user }));
},
// Set user from SSR pre-load (no fetch required)
setUser: (user: InternalUser) => {
set({ user, token: null, refreshToken: null, isAuthenticated: true, isLoading: false });
},
// Set loading state
setLoading: (isLoading: boolean) => {
update(state => ({ ...state, isLoading }));
}
updateUser: (user: User) => { update(state => ({ ...state, user })); },
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
};
}

View File

@@ -1,46 +1,46 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
let email = '';
let password = '';
let totpCode = '';
let isLoading = false;
let showTwoFactor = false;
let errorMessage = '';
onMount(() => {
// Redirect if already authenticated
if ($auth.isAuthenticated) {
goto('/');
}
});
async function handleLogin() {
if (!email || !password) {
errorMessage = 'Por favor completa todos los campos';
return;
}
isLoading = true;
errorMessage = '';
try {
await auth.login({
email,
password,
tenant_slug: 'aduanasoft-demo',
tenant_slug: 'aduanasoft',
totp_code: totpCode || undefined
});
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
goto('/');
} catch (error: any) {
console.error('Login error:', error);
// Check if 2FA is required
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
showTwoFactor = true;
@@ -53,7 +53,7 @@
isLoading = false;
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleLogin();
@@ -61,44 +61,50 @@
}
</script>
<svelte:head>
<title>Acceso Admin - ServiceManager</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950 p-4 font-sans">
<div class="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden border border-gray-200 dark:border-gray-800">
<div
class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950 p-4 font-sans"
>
<div
class="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden border border-gray-200 dark:border-gray-800"
>
<!-- Left Side: Internal Branding -->
<div class="hidden md:flex flex-col justify-between p-12 bg-gray-900 text-white relative overflow-hidden">
<div
class="hidden md:flex flex-col justify-between p-12 bg-gray-900 text-white relative overflow-hidden"
>
<!-- Grid pattern overlay -->
<div class="absolute inset-0 opacity-10" style="background-image: radial-gradient(white 1px, transparent 1px); background-size: 30px 30px;"></div>
<div
class="absolute inset-0 opacity-10"
style="background-image: radial-gradient(white 1px, transparent 1px); background-size: 30px 30px;"
/>
<div class="relative z-10">
<div class="flex items-center space-x-3 mb-6">
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
<Icon name="server" class="w-6 h-6 text-blue-400" />
</div>
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
<Icon name="server" className="w-6 h-6 text-blue-400" />
</div>
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
</div>
<h1 class="text-3xl font-bold tracking-tight mb-4">
Panel de Administración
</h1>
<h1 class="text-3xl font-bold tracking-tight mb-4">Panel de Administración</h1>
<p class="text-gray-400 text-sm leading-relaxed max-w-sm">
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado.
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios.
Acceso restringido únicamente a personal autorizado.
</p>
</div>
<div class="relative z-10 mt-12">
<div class="space-y-3">
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
<Icon name="check-circle" class="w-4 h-4 text-green-500" />
<span>System Status: Operational</span>
<Icon name="check-circle" className="w-4 h-4 text-green-500" />
<span>System Status: Operational</span>
</div>
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
<Icon name="shield" class="w-4 h-4 text-blue-500" />
<span>256-bit Encryption Enabled</span>
<Icon name="shield" className="w-4 h-4 text-blue-500" />
<span>256-bit Encryption Enabled</span>
</div>
</div>
</div>
@@ -106,106 +112,129 @@
<!-- Right Side: Login Form -->
<div class="p-8 md:p-12 flex flex-col justify-center">
<div class="max-w-sm mx-auto w-full">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
</div>
<div class="max-w-sm mx-auto w-full">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
</div>
<form on:submit|preventDefault={handleLogin} class="space-y-5">
{#if errorMessage}
<div class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3">
<Icon name="alert-triangle" class="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
</div>
{/if}
<form on:submit|preventDefault={handleLogin} class="space-y-5">
{#if errorMessage}
<div
class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3"
>
<Icon
name="alert-triangle"
className="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5"
/>
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
</div>
{/if}
{#if !showTwoFactor}
<div class="space-y-4">
<div>
<label for="email" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Usuario / Correo</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
<Icon name="user" class="w-5 h-5" />
</div>
<input
id="email"
type="email"
bind:value={email}
on:keydown={handleKeyDown}
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
placeholder="admin@aduanasoft.com"
required
disabled={isLoading}
/>
</div>
</div>
{#if !showTwoFactor}
<div class="space-y-4">
<div>
<label
for="email"
class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1"
>Usuario / Correo</label
>
<div class="relative group">
<div
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
>
<Icon name="user" className="w-5 h-5" />
</div>
<input
id="email"
type="email"
bind:value={email}
on:keydown={handleKeyDown}
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
placeholder="admin@aduanasoft.com"
required
disabled={isLoading}
/>
</div>
</div>
<div>
<label for="password" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Clave de Acceso</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
<Icon name="lock" class="w-5 h-5" />
</div>
<input
id="password"
type="password"
bind:value={password}
on:keydown={handleKeyDown}
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
placeholder="••••••••••••"
required
disabled={isLoading}
/>
</div>
</div>
</div>
{:else}
<!-- 2FA Input -->
<div class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30">
<label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label>
<div class="relative">
<input
id="code"
type="text"
bind:value={totpCode}
on:keydown={handleKeyDown}
class="form-input w-full py-3 rounded border-blue-300 dark:border-blue-700 focus:ring-blue-500 focus:border-blue-500 text-center tracking-[0.5em] font-mono text-lg bg-white dark:bg-gray-800"
placeholder="000000"
maxlength="6"
required
disabled={isLoading}
autofocus
/>
</div>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-2 text-center">
Consulte su dispositivo autenticador
</p>
</div>
{/if}
<div>
<label
for="password"
class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1"
>Clave de Acceso</label
>
<div class="relative group">
<div
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
>
<Icon name="lock" className="w-5 h-5" />
</div>
<input
id="password"
type="password"
bind:value={password}
on:keydown={handleKeyDown}
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
placeholder="••••••••••••"
required
disabled={isLoading}
/>
</div>
</div>
</div>
{:else}
<!-- 2FA Input -->
<div
class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30"
>
<label
for="code"
class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center"
>Verificación de Seguridad</label
>
<div class="relative">
<input
id="code"
type="text"
bind:value={totpCode}
on:keydown={handleKeyDown}
class="form-input w-full py-3 rounded border-blue-300 dark:border-blue-700 focus:ring-blue-500 focus:border-blue-500 text-center tracking-[0.5em] font-mono text-lg bg-white dark:bg-gray-800"
placeholder="000000"
maxlength="6"
required
disabled={isLoading}
autofocus
/>
</div>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-2 text-center">
Consulte su dispositivo autenticador
</p>
</div>
{/if}
<div class="pt-4">
<button
type="submit"
class="w-full flex justify-center py-2.5 px-4 rounded bg-gray-900 dark:bg-gray-700 text-white font-medium hover:bg-gray-800 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader" class="w-4 h-4 animate-spin mr-2" />
Autenticando...
{:else}
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
{/if}
</button>
</div>
</form>
</div>
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p>
</div>
<div class="pt-4">
<button
type="submit"
class="w-full flex justify-center py-2.5 px-4 rounded bg-gray-900 dark:bg-gray-700 text-white font-medium hover:bg-gray-800 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader" className="w-4 h-4 animate-spin mr-2" />
Autenticando...
{:else}
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
{/if}
</button>
</div>
</form>
</div>
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">
Aduanasoft Internal Systems © 2024
</p>
</div>
</div>
</div>
</div>

View File

@@ -1,39 +1,35 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
// Puerto: dentro del contenedor siempre 3000; Docker mapea 3001:3000 al host
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
// HMR: el browser llega al contenedor a través del puerto 3001 del host
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
preview: {
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
plugins: [sveltekit()],
server: {
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
},
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
preview: {
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
});

BIN
migrations.sql Normal file

Binary file not shown.

View File

@@ -0,0 +1,43 @@
# Scripts PowerShell en ServiceManagerWeb
## security-test-data.ps1
- **Propósito:** Genera o limpia datos de prueba para análisis de seguridad.
- **Uso:**
- `. ools\security-test-data.ps1 generar` → Genera datos de prueba.
- `. ools\security-test-data.ps1 limpiar` → Limpia los datos de prueba.
- **Funcionamiento:** Verifica que el contenedor backend esté corriendo y ejecuta el script Python correspondiente dentro del contenedor.
## test_critical_sync.ps1
- **Propósito:** Verifica la sincronización de incidentes críticos entre Auditoría y Seguridad.
- **Pasos:**
1. Login como admin y obtiene token.
2. Consulta estadísticas del módulo Auditoría.
3. Consulta estadísticas del módulo Seguridad.
- **Resultado:** Muestra si los incidentes críticos están sincronizados.
## test_tenant_update.ps1
- **Propósito:** Prueba el endpoint de actualización de tenants.
- **Pasos:**
1. Login como admin.
2. Obtiene lista de tenants.
3. Actualiza el tenant (ejemplo: teléfono y status).
- **Resultado:** Verifica que la actualización funcione correctamente.
## test_manual.ps1
- **Propósito:** Pruebas manuales de endpoints clave.
- **Pasos:**
1. Login y obtención de token.
2. Listar categorías.
3. Crear ticket con SLA automático.
- **Resultado:** Permite validar manualmente el flujo de API.
## test_frontend_integration.ps1
- **Propósito:** Verifica la integración entre frontend y backend.
- **Pasos:**
1. Verifica servicios Docker.
2. Login y obtención de token.
3. Verifica tickets con SLA.
- **Resultado:** Confirma que el frontend puede consumir correctamente el backend.
---
**Recomendación:** Conserva estos scripts para testing e integración. Documenta cualquier script nuevo siguiendo este formato.

View File

@@ -0,0 +1,29 @@
# Guía rápida de scripts esenciales
## 1. setup-dev.sh
Configura el entorno de desarrollo completo (servicios, dependencias).
## 2. seed_data.py
Inicializa categorías, sistemas y usuarios demo.
## 3. seed_tickets.py
Genera tickets de prueba (requiere seed_data.py ejecutado).
## 4. run_tests.sh
Ejecuta la suite de tests de integración.
## 5. reset_passwords.py
Resetea contraseñas de usuarios demo para pruebas de login.
## 6. generate_sla_test_data.py
Crea tickets con diferentes estados de SLA.
## 7. generate_security_test_data.py
Genera logs de auditoría de prueba.
## 8. check_tenants.py
Lista y audita los tenants existentes.
---
**Recomendación:** Ejecuta los scripts en este orden para tener un entorno funcional y datos de prueba completos. Elimina los scripts de debugging/manuales si no los necesitas para troubleshooting avanzado.

View File

@@ -1,7 +0,0 @@
from app.models.ticket import Ticket
from app.models import relationships # ensure relationships are loaded
from sqlalchemy import inspect
mapper = inspect(Ticket)
print("Relationships:", [r.key for r in mapper.relationships])
print("Columns:", [c.key for c in mapper.columns])

View File

@@ -1,40 +0,0 @@
"""Check SLA state of tickets and categories"""
import asyncio
import os
import sys
sys.path.insert(0, '/app')
os.chdir('/app')
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def run():
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url)
async with engine.connect() as c:
print("=== CATEGORIES SLA HOURS ===")
r = await c.execute(text(
"SELECT name, sla_response_hours, sla_resolution_hours "
"FROM ticket_categories "
"ORDER BY name"
))
for row in r.fetchall():
print(f" {row[0]}: response={row[1]}h, resolution={row[2]}h")
print("\n=== TICKETS SLA DATES ===")
r2 = await c.execute(text(
"SELECT ticket_number, category_id, sla_response_due, sla_resolution_due "
"FROM tickets "
"ORDER BY created_at "
"LIMIT 10"
))
for row in r2.fetchall():
print(f" {row[0]}: cat={str(row[1])[:8] if row[1] else 'None'}, sla_resp={row[2]}, sla_res={row[3]}")
await engine.dispose()
asyncio.run(run())

View File

@@ -1,41 +0,0 @@
"""Debug: check if ticket's category_id maps to a valid category and what tenant it belongs to"""
import asyncio
import os
import sys
sys.path.insert(0, '/app')
os.chdir('/app')
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def run():
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url)
async with engine.connect() as c:
# Check tickets and their category names via direct JOIN
r = await c.execute(text("""
SELECT t.ticket_number, t.category_id,
cat.name as category_name, cat.tenant_id as cat_tenant,
t.tenant_id as ticket_tenant
FROM tickets t
LEFT JOIN ticket_categories cat ON cat.id = t.category_id
WHERE t.category_id IS NOT NULL
LIMIT 10
"""))
print("=== TICKET -> CATEGORY JOIN ===")
for row in r.fetchall():
match = "✓ SAME TENANT" if row[3] == row[4] else "✗ DIFFERENT TENANT"
print(f" {row[0]}: cat_id={str(row[1])[:8]}, cat_name={row[2]}, {match}")
# Check what tenant aduanasoft-demo is
r2 = await c.execute(text("SELECT id, slug FROM tenants WHERE slug='aduanasoft-demo'"))
tenant = r2.fetchone()
print(f"\nTenant aduanasoft-demo: {tenant[0] if tenant else 'NOT FOUND'}")
await engine.dispose()
asyncio.run(run())

View File

@@ -1,49 +0,0 @@
"""Debug: check SQLAlchemy ORM category loading"""
import asyncio
import os
import sys
sys.path.insert(0, '/app')
os.chdir('/app')
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, selectinload
from sqlalchemy import select
from app.models.ticket import Ticket
from app.models.category import Category
async def run():
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url, echo=True)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# Test selectinload
result = await session.execute(
select(Ticket)
.options(selectinload(Ticket.category))
.where(Ticket.category_id != None)
.limit(3)
)
tickets = result.scalars().all()
print(f"\n=== ORM RESULTS ({len(tickets)} tickets) ===")
for t in tickets:
print(f" {t.ticket_number}: category_id={t.category_id}, category={t.category}")
if t.category:
print(f" -> category.name={t.category.name}")
else:
print(f" -> category is None!")
# Check if Category model can be queried directly
r2 = await session.execute(select(Category).limit(3))
cats = r2.scalars().all()
print(f"\n=== DIRECT CATEGORY QUERY ({len(cats)} categories) ===")
for c in cats:
print(f" id={c.id}, name={c.name}")
await engine.dispose()
asyncio.run(run())

22
scripts/reset-fabrica.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Script para resetear datos a estado de fábrica (solo datos, no afecta estructura ni funcionalidad)
set -e
echo "🧹 Reseteando datos del sistema..."
# 1. Eliminar datos de tickets, comentarios, logs, auditoría, uploads
# (Ejemplo: usando comandos SQL directos desde el contenedor)
docker-compose exec backend psql -U servicemanager -d servicemanager -c "TRUNCATE tickets, ticket_comments, audit_logs, uploads RESTART IDENTITY CASCADE;"
echo "✅ Datos eliminados."
# 2. Volver a poblar datos demo
docker-compose exec backend python scripts/seed_data.py
docker-compose exec backend python scripts/seed_tickets.py
docker-compose exec backend python scripts/generate_sla_test_data.py
docker-compose exec backend python scripts/generate_security_test_data.py
docker-compose exec backend python scripts/reset_passwords.py
echo "🎉 Sistema restaurado a estado de fábrica demo."

11
show_context.py Normal file
View File

@@ -0,0 +1,11 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
lines = f.readlines()
# Mostrar contexto alrededor de lineas con tenant_id
targets = [51, 92, 159, 200, 307, 384]
for t in targets:
print(f"\n=== Linea {t} ===")
start = max(0, t-5)
end = min(len(lines), t+5)
for i in range(start, end):
print(f"{i+1}: {lines[i].rstrip()}")