Compare commits
12 Commits
v1.17.0
...
3f7b166767
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f7b166767 | |||
| 6bc5145b9c | |||
| 597286fff0 | |||
| e141701567 | |||
| 7003e5cd80 | |||
| d8232fda7c | |||
| 16b3dc5d47 | |||
| 3b46f48655 | |||
| f10b15d91b | |||
| 49dfb3ef24 | |||
| b187aa1b46 | |||
| cd3d7e816f |
Binary file not shown.
@@ -1,3 +1,4 @@
|
|||||||
|
from typing import Optional
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
@@ -15,7 +16,48 @@ from app.models.tenant import Tenant
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Esquema OAuth2 centralizado — auth.py importa desde aquí
|
# Esquema OAuth2 centralizado — auth.py importa desde aquí
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
# Soporta: 1) Authorization: Bearer header (Swagger/API clients)
|
||||||
|
# 2) Cookie access_token HttpOnly (apps web)
|
||||||
|
_bearer_scheme = OAuth2PasswordBearer(
|
||||||
|
tokenUrl=f"/{settings.API_VERSION}/auth/login",
|
||||||
|
auto_error=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def oauth2_scheme(
|
||||||
|
request: Request,
|
||||||
|
bearer_token: Optional[str] = Depends(_bearer_scheme),
|
||||||
|
) -> str:
|
||||||
|
"""Extrae JWT desde header Authorization (prioridad) o cookie del frontend correcto.
|
||||||
|
|
||||||
|
Usa el header X-App para seleccionar la cookie:
|
||||||
|
- X-App: internal → solo 'internal_access_token'
|
||||||
|
- X-App: client → solo 'client_access_token'
|
||||||
|
- sin header → prueba ambas (compatibilidad con Swagger/CLI)
|
||||||
|
"""
|
||||||
|
if bearer_token:
|
||||||
|
return bearer_token
|
||||||
|
|
||||||
|
app_hint = request.headers.get("X-App", "").lower()
|
||||||
|
if app_hint == "internal":
|
||||||
|
token = request.cookies.get("internal_access_token")
|
||||||
|
elif app_hint == "client":
|
||||||
|
token = request.cookies.get("client_access_token")
|
||||||
|
else:
|
||||||
|
# Fallback para Swagger, tests y clientes sin header
|
||||||
|
token = (
|
||||||
|
request.cookies.get("internal_access_token")
|
||||||
|
or request.cookies.get("client_access_token")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -47,9 +89,10 @@ async def get_current_user(
|
|||||||
raise HTTPException(status_code=400, detail="Inactive user")
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
|
|
||||||
# Enforce that tenant header (if present) matches the authenticated user's tenant.
|
# 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)
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Tenant header does not match authenticated user",
|
detail="Tenant header does not match authenticated user",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Auth Schemas - ServiceManagerWeb
|
Auth Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
Pydantic schemas para autenticación y autorización.
|
Pydantic schemas para autenticación y autorización.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
@@ -12,7 +12,7 @@ class LoginRequest(BaseModel):
|
|||||||
"""Schema para solicitud de login."""
|
"""Schema para solicitud de login."""
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
tenant_slug: str
|
tenant_slug: Optional[str] = None
|
||||||
totp_code: Optional[str] = None
|
totp_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -53,28 +53,28 @@ class TwoFactorSetupResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TwoFactorEnableRequest(BaseModel):
|
class TwoFactorEnableRequest(BaseModel):
|
||||||
"""Código TOTP para confirmar y activar 2FA."""
|
"""Código TOTP para confirmar y activar 2FA."""
|
||||||
totp_code: str
|
totp_code: str
|
||||||
|
|
||||||
|
|
||||||
class TwoFactorEnableResponse(BaseModel):
|
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
|
enabled: bool
|
||||||
backup_codes: List[str]
|
backup_codes: List[str]
|
||||||
|
|
||||||
|
|
||||||
class TwoFactorDisableRequest(BaseModel):
|
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
|
totp_code: Optional[str] = None
|
||||||
backup_code: Optional[str] = None
|
backup_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Cambio de contraseña
|
# Cambio de contraseña
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
class ChangePasswordRequest(BaseModel):
|
||||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||||
current_password: str
|
current_password: str
|
||||||
new_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):
|
class ForgotPasswordRequest(BaseModel):
|
||||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
class ResetPasswordRequest(BaseModel):
|
class ResetPasswordRequest(BaseModel):
|
||||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||||
token: str
|
token: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Authentication Endpoints - ServiceManagerWeb
|
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
|
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -21,6 +21,15 @@ from app.services.audit_service import AuditService
|
|||||||
from app.services.token_service import TokenService
|
from app.services.token_service import TokenService
|
||||||
from app.api.deps import oauth2_scheme, get_current_user
|
from app.api.deps import oauth2_scheme, get_current_user
|
||||||
from app.core.cache import cache, cache_key
|
from app.core.cache import cache, cache_key
|
||||||
|
from app.core.limiter import limiter
|
||||||
|
|
||||||
|
# Nombres de cookie por tipo de usuario
|
||||||
|
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."""
|
||||||
|
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
|
||||||
from app.api.schemas.auth import (
|
from app.api.schemas.auth import (
|
||||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||||
@@ -38,9 +47,11 @@ settings = get_settings()
|
|||||||
# ===================================
|
# ===================================
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
async def login(
|
async def login(
|
||||||
login_data: LoginRequest,
|
login_data: LoginRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
response: Response,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -77,25 +88,23 @@ async def login(
|
|||||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1. Validar tenant
|
# 1. Validar tenant - por slug si viene, sino buscar por email
|
||||||
tenant_result = await db.execute(
|
if login_data.tenant_slug:
|
||||||
select(Tenant).where(Tenant.slug == 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:
|
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
|
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.
|
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||||
ident_key = None
|
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()
|
email_norm = login_data.email.strip().lower()
|
||||||
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
|
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
|
||||||
ident_count = await cache.incr(ident_key, 1)
|
ident_count = await cache.incr(ident_key, 1)
|
||||||
@@ -131,22 +140,25 @@ async def login(
|
|||||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Buscar usuario en base de datos (aislado por tenant)
|
# 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
|
||||||
query = select(User).where(
|
if tenant:
|
||||||
User.email == login_data.email,
|
query = select(User).where(
|
||||||
User.tenant_id == tenant.id,
|
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)
|
result = await db.execute(query)
|
||||||
user = result.scalar_one_or_none()
|
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):
|
if not user or not security.verify_password(login_data.password, user.password_hash):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Login failed - invalid credentials",
|
"Login failed - invalid credentials",
|
||||||
email=login_data.email
|
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:
|
if user:
|
||||||
try:
|
try:
|
||||||
await AuditService.log(
|
await AuditService.log(
|
||||||
@@ -167,7 +179,7 @@ async def login(
|
|||||||
detail="Invalid credentials",
|
detail="Invalid credentials",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Verificar si está activo
|
# 4. Verificar si está activo
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Login failed - user inactive",
|
"Login failed - user inactive",
|
||||||
@@ -178,19 +190,19 @@ async def login(
|
|||||||
detail="User inactive",
|
detail="User inactive",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. Verificar 2FA si está habilitado
|
# 5. Verificar 2FA si está habilitado
|
||||||
if user.totp_enabled:
|
if user.totp_enabled:
|
||||||
if not login_data.totp_code:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
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):
|
if not security.verify_totp(user.totp_secret, login_data.totp_code):
|
||||||
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
|
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Código 2FA inválido o expirado"
|
detail="Código 2FA inválido o expirado"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create tokens
|
# Create tokens
|
||||||
@@ -222,7 +234,7 @@ async def login(
|
|||||||
detail="Service temporarily unavailable",
|
detail="Service temporarily unavailable",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Registrar login exitoso en auditoría
|
# Registrar login exitoso en auditorÃa
|
||||||
try:
|
try:
|
||||||
await AuditService.log(
|
await AuditService.log(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -247,7 +259,20 @@ async def login(
|
|||||||
# Best-effort: clear per-identity limiter on success.
|
# Best-effort: clear per-identity limiter on success.
|
||||||
if ident_key:
|
if ident_key:
|
||||||
await cache.delete(ident_key)
|
await cache.delete(ident_key)
|
||||||
|
|
||||||
|
# Cookie diferenciada por rol para aislar sesiones entre frontends
|
||||||
|
cookie_name = _cookie_name_for_role(
|
||||||
|
user.role.value if hasattr(user.role, "value") else user.role
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
key=cookie_name,
|
||||||
|
value=access_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.is_production(),
|
||||||
|
samesite="strict" if settings.is_production() else "lax",
|
||||||
|
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||||
|
)
|
||||||
|
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
@@ -259,6 +284,7 @@ async def login(
|
|||||||
"last_name": user.last_name,
|
"last_name": user.last_name,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
"tenant_id": str(user.tenant_id),
|
"tenant_id": str(user.tenant_id),
|
||||||
|
"tenant_slug": tenant.slug if tenant else str(user.tenant_id),
|
||||||
"is_active": user.is_active,
|
"is_active": user.is_active,
|
||||||
"is_two_factor_enabled": user.totp_enabled or False,
|
"is_two_factor_enabled": user.totp_enabled or False,
|
||||||
"created_at": user.created_at.isoformat() if user.created_at else None
|
"created_at": user.created_at.isoformat() if user.created_at else None
|
||||||
@@ -330,6 +356,7 @@ async def refresh_token(
|
|||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(
|
async def logout(
|
||||||
|
response: Response,
|
||||||
token: str = Depends(oauth2_scheme),
|
token: str = Depends(oauth2_scheme),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -367,7 +394,7 @@ async def logout(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
||||||
|
|
||||||
# Registrar logout en auditoría
|
# Registrar logout en auditorÃa
|
||||||
try:
|
try:
|
||||||
import uuid
|
import uuid
|
||||||
user_id = uuid.UUID(payload["sub"])
|
user_id = uuid.UUID(payload["sub"])
|
||||||
@@ -387,7 +414,10 @@ async def logout(
|
|||||||
logger.warning("Failed to log audit entry", error=str(e))
|
logger.warning("Failed to log audit entry", error=str(e))
|
||||||
|
|
||||||
logger.info("Logout successful", user_id=payload["sub"])
|
logger.info("Logout successful", user_id=payload["sub"])
|
||||||
|
|
||||||
|
# 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"}
|
return {"message": "Successfully logged out"}
|
||||||
|
|
||||||
|
|
||||||
@@ -474,7 +504,7 @@ async def get_2fa_status(
|
|||||||
current_user: User = Depends(get_current_user),
|
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:
|
Returns:
|
||||||
Estado de 2FA del usuario autenticado.
|
Estado de 2FA del usuario autenticado.
|
||||||
@@ -488,10 +518,10 @@ async def setup_2fa(
|
|||||||
db: AsyncSession = Depends(get_db),
|
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.
|
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.
|
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Secret y QR URI para escanear con la app autenticadora.
|
Secret y QR URI para escanear con la app autenticadora.
|
||||||
@@ -499,7 +529,7 @@ async def setup_2fa(
|
|||||||
new_secret = security.generate_totp_secret()
|
new_secret = security.generate_totp_secret()
|
||||||
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
|
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
|
current_user.totp_secret = new_secret
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -515,29 +545,29 @@ async def enable_2fa(
|
|||||||
db: AsyncSession = Depends(get_db),
|
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.
|
Requiere que /2fa/setup haya sido llamado previamente.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Código TOTP generado por la app autenticadora.
|
data: Código TOTP generado por la app autenticadora.
|
||||||
|
|
||||||
Returns:
|
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:
|
if not current_user.totp_secret:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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):
|
if not security.verify_totp(current_user.totp_secret, data.totp_code):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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()
|
backup_codes = security.generate_backup_codes()
|
||||||
current_user.totp_enabled = True
|
current_user.totp_enabled = True
|
||||||
current_user.backup_codes = backup_codes
|
current_user.backup_codes = backup_codes
|
||||||
@@ -565,21 +595,21 @@ async def disable_2fa(
|
|||||||
db: AsyncSession = Depends(get_db),
|
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:
|
Args:
|
||||||
data: totp_code o backup_code para verificar identidad.
|
data: totp_code o backup_code para verificar identidad.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Mensaje de confirmación.
|
Mensaje de confirmación.
|
||||||
"""
|
"""
|
||||||
if not current_user.totp_enabled:
|
if not current_user.totp_enabled:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
verified = False
|
||||||
|
|
||||||
if data.totp_code:
|
if data.totp_code:
|
||||||
@@ -587,7 +617,7 @@ async def disable_2fa(
|
|||||||
elif data.backup_code and current_user.backup_codes:
|
elif data.backup_code and current_user.backup_codes:
|
||||||
if data.backup_code in current_user.backup_codes:
|
if data.backup_code in current_user.backup_codes:
|
||||||
verified = True
|
verified = True
|
||||||
# Invalidar el código de respaldo usado
|
# Invalidar el código de respaldo usado
|
||||||
current_user.backup_codes = [
|
current_user.backup_codes = [
|
||||||
c for c in current_user.backup_codes if c != data.backup_code
|
c for c in current_user.backup_codes if c != data.backup_code
|
||||||
]
|
]
|
||||||
@@ -595,7 +625,7 @@ async def disable_2fa(
|
|||||||
if not verified:
|
if not verified:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
# Deshabilitar 2FA
|
||||||
@@ -616,7 +646,7 @@ async def disable_2fa(
|
|||||||
|
|
||||||
logger.info("2FA disabled", user_id=str(current_user.id))
|
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)
|
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
||||||
@@ -626,32 +656,32 @@ async def change_password(
|
|||||||
db: AsyncSession = Depends(get_db),
|
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.
|
Verifica la contraseña actual antes de actualizar.
|
||||||
Requiere autenticación activa.
|
Requiere autenticación activa.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Validar longitud mínima
|
# Validar longitud mÃnima
|
||||||
if len(data.new_password) < 8:
|
if len(data.new_password) < 8:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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):
|
if not security.verify_password(data.current_password, current_user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
# No permitir que la nueva sea igual a la actual
|
||||||
if security.verify_password(data.new_password, current_user.password_hash):
|
if security.verify_password(data.new_password, current_user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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)
|
current_user.password_hash = security.hash_password(data.new_password)
|
||||||
@@ -669,11 +699,11 @@ async def change_password(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.info("Password changed", user_id=str(current_user.id))
|
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
|
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
|
||||||
@@ -681,15 +711,17 @@ _RESET_KEY_PREFIX = "pwd_reset:"
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
|
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
async def forgot_password(
|
async def forgot_password(
|
||||||
|
request: Request,
|
||||||
data: ForgotPasswordRequest,
|
data: ForgotPasswordRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
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
|
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
|
import secrets
|
||||||
from redis.asyncio import from_url as redis_from_url
|
from redis.asyncio import from_url as redis_from_url
|
||||||
@@ -705,9 +737,9 @@ async def forgot_password(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
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)
|
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
|
# Generar token seguro
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
@@ -727,7 +759,7 @@ async def forgot_password(
|
|||||||
|
|
||||||
await send_email(
|
await send_email(
|
||||||
to_email=user.email,
|
to_email=user.email,
|
||||||
subject="Restablece tu contraseña — ServiceManager",
|
subject="Restablece tu contraseña — ServiceManager",
|
||||||
html_content=html,
|
html_content=html,
|
||||||
text_content=text,
|
text_content=text,
|
||||||
)
|
)
|
||||||
@@ -744,16 +776,18 @@ async def forgot_password(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.info("Password reset email sent", user_id=str(user.id))
|
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)
|
@router.post("/reset-password", status_code=status.HTTP_200_OK)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
async def reset_password(
|
async def reset_password(
|
||||||
|
request: Request,
|
||||||
data: ResetPasswordRequest,
|
data: ResetPasswordRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
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.
|
El token es de un solo uso: se elimina de Redis al usarse.
|
||||||
"""
|
"""
|
||||||
@@ -764,7 +798,7 @@ async def reset_password(
|
|||||||
if len(data.new_password) < 8:
|
if len(data.new_password) < 8:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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}"
|
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
|
||||||
@@ -775,7 +809,7 @@ async def reset_password(
|
|||||||
if not user_id_str:
|
if not user_id_str:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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)
|
# Eliminar token inmediatamente (un solo uso)
|
||||||
@@ -806,4 +840,4 @@ async def reset_password(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.info("Password reset completed", user_id=str(user.id))
|
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."}
|
||||||
@@ -7,7 +7,7 @@ Accesible por ADMIN y SUPPORT_MANAGER.
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, and_, case, text
|
from sqlalchemy import select, func, and_, case, text, literal_column
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import uuid
|
import uuid
|
||||||
@@ -505,20 +505,23 @@ async def get_report_trends(
|
|||||||
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||||
|
|
||||||
# Tickets creados por día
|
# Tickets creados por día
|
||||||
|
# literal_column("'day'") evita que SQLAlchemy genere múltiples parámetros
|
||||||
|
# ($1, $4, $5) para 'day', lo que confunde a PostgreSQL en el GROUP BY.
|
||||||
|
_day_lit = literal_column("'day'")
|
||||||
created_rows = (await db.execute(
|
created_rows = (await db.execute(
|
||||||
select(
|
select(
|
||||||
func.date_trunc("day", Ticket.created_at).label("day"),
|
func.date_trunc(_day_lit, Ticket.created_at).label("day"),
|
||||||
func.count(Ticket.id).label("cnt"),
|
func.count(Ticket.id).label("cnt"),
|
||||||
)
|
)
|
||||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||||
.group_by(func.date_trunc("day", Ticket.created_at))
|
.group_by(func.date_trunc(_day_lit, Ticket.created_at))
|
||||||
.order_by(func.date_trunc("day", Ticket.created_at))
|
.order_by(func.date_trunc(_day_lit, Ticket.created_at))
|
||||||
)).all()
|
)).all()
|
||||||
|
|
||||||
# Tickets resueltos por día (según resolved_at)
|
# Tickets resueltos por día (según resolved_at)
|
||||||
resolved_rows = (await db.execute(
|
resolved_rows = (await db.execute(
|
||||||
select(
|
select(
|
||||||
func.date_trunc("day", Ticket.resolved_at).label("day"),
|
func.date_trunc(_day_lit, Ticket.resolved_at).label("day"),
|
||||||
func.count(Ticket.id).label("cnt"),
|
func.count(Ticket.id).label("cnt"),
|
||||||
)
|
)
|
||||||
.where(and_(
|
.where(and_(
|
||||||
@@ -526,8 +529,8 @@ async def get_report_trends(
|
|||||||
Ticket.resolved_at >= period_start,
|
Ticket.resolved_at >= period_start,
|
||||||
Ticket.resolved_at.isnot(None),
|
Ticket.resolved_at.isnot(None),
|
||||||
))
|
))
|
||||||
.group_by(func.date_trunc("day", Ticket.resolved_at))
|
.group_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||||
.order_by(func.date_trunc("day", Ticket.resolved_at))
|
.order_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||||
)).all()
|
)).all()
|
||||||
|
|
||||||
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}
|
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import uuid
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.api.deps import get_current_user, get_current_tenant
|
from app.api.deps import get_current_user, get_current_tenant
|
||||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
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.tenant import Tenant
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.system import System
|
from app.models.system import System
|
||||||
@@ -28,92 +28,27 @@ from app.api.v1.helpers import (
|
|||||||
safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict
|
safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict
|
||||||
)
|
)
|
||||||
from app.services.audit_service import AuditService
|
from app.services.audit_service import AuditService
|
||||||
|
from app.services.ticket_service import TicketService, get_ticket_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
|
@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"""
|
"""Crear un nuevo ticket"""
|
||||||
max_retries = 3
|
return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id)
|
||||||
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.")
|
|
||||||
|
|
||||||
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.")
|
|
||||||
|
|
||||||
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)}")
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[TicketResponse])
|
@router.get("/", response_model=List[TicketResponse])
|
||||||
async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = None, priority: Optional[str] = None,
|
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)):
|
db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Obtener tickets con filtros opcionales"""
|
"""Obtener tickets con filtros opcionales"""
|
||||||
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
|
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 = query.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
|
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
|
||||||
@@ -136,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,
|
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)):
|
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)."""
|
"""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")
|
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)
|
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.
|
# SUPPORT_MANAGER solo ve su propio tenant.
|
||||||
# ADMIN ve todos los tenants (es el administrador de la plataforma).
|
# 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 = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||||
|
|
||||||
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
|
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
|
||||||
@@ -201,7 +136,7 @@ async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current
|
|||||||
"""Obtener un ticket por ID"""
|
"""Obtener un ticket por ID"""
|
||||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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)
|
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.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user))
|
query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user))
|
||||||
@@ -218,7 +153,7 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
|
|||||||
"""Actualizar un ticket"""
|
"""Actualizar un ticket"""
|
||||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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)
|
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.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -45,8 +45,12 @@ async def read_users(
|
|||||||
- role: filtrar por rol
|
- role: filtrar por rol
|
||||||
- is_active: filtrar por estado activo
|
- is_active: filtrar por estado activo
|
||||||
"""
|
"""
|
||||||
# ✅ CORREGIDO: Filtrar por tenant_id
|
# ADMIN global ve todos los tenants; el resto solo ve su propio tenant
|
||||||
query = select(User).where(User.tenant_id == current_user.tenant_id)
|
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
|
# Aplicar filtros opcionales
|
||||||
if role:
|
if role:
|
||||||
@@ -144,10 +148,13 @@ async def read_user(
|
|||||||
|
|
||||||
✅ Implementa multi-tenancy: solo permite acceso a usuarios del propio tenant.
|
✅ Implementa multi-tenancy: solo permite acceso a usuarios del propio tenant.
|
||||||
"""
|
"""
|
||||||
query = select(User).where(
|
if current_user.role.value == 'ADMIN':
|
||||||
User.id == user_id,
|
query = select(User).where(User.id == user_id)
|
||||||
User.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant
|
else:
|
||||||
)
|
query = select(User).where(
|
||||||
|
User.id == user_id,
|
||||||
|
User.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -183,11 +190,14 @@ async def update_user(
|
|||||||
detail="You don't have permission to update users"
|
detail="You don't have permission to update users"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Buscar usuario
|
# Buscar usuario - ADMIN global puede editar cualquier tenant
|
||||||
query = select(User).where(
|
if current_user.role.value == "ADMIN":
|
||||||
User.id == user_id,
|
query = select(User).where(User.id == user_id)
|
||||||
User.tenant_id == current_user.tenant_id
|
else:
|
||||||
)
|
query = select(User).where(
|
||||||
|
User.id == user_id,
|
||||||
|
User.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
db_user = result.scalar_one_or_none()
|
db_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -290,11 +300,14 @@ async def delete_user(
|
|||||||
detail="You cannot delete yourself"
|
detail="You cannot delete yourself"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Buscar usuario
|
# Buscar usuario - ADMIN global puede editar cualquier tenant
|
||||||
query = select(User).where(
|
if current_user.role.value == "ADMIN":
|
||||||
User.id == user_id,
|
query = select(User).where(User.id == user_id)
|
||||||
User.tenant_id == current_user.tenant_id
|
else:
|
||||||
)
|
query = select(User).where(
|
||||||
|
User.id == user_id,
|
||||||
|
User.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
db_user = result.scalar_one_or_none()
|
db_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -367,11 +380,14 @@ async def activate_user(
|
|||||||
detail="You don't have permission to activate users"
|
detail="You don't have permission to activate users"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Buscar usuario
|
# Buscar usuario - ADMIN global puede editar cualquier tenant
|
||||||
query = select(User).where(
|
if current_user.role.value == "ADMIN":
|
||||||
User.id == user_id,
|
query = select(User).where(User.id == user_id)
|
||||||
User.tenant_id == current_user.tenant_id
|
else:
|
||||||
)
|
query = select(User).where(
|
||||||
|
User.id == user_id,
|
||||||
|
User.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
db_user = result.scalar_one_or_none()
|
db_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,11 @@ async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) ->
|
|||||||
last_ticket_number = result.scalar_one_or_none()
|
last_ticket_number = result.scalar_one_or_none()
|
||||||
|
|
||||||
if last_ticket_number:
|
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
|
next_number = last_number + 1
|
||||||
else:
|
else:
|
||||||
next_number = 1
|
next_number = 1
|
||||||
|
|
||||||
return f"TK-{next_number:06d}"
|
return f"TK-{next_number:06d}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Configuración centralizada usando Pydantic Settings v2
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import List, Optional
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic import field_validator, Field
|
from pydantic import field_validator, Field
|
||||||
import os
|
import os
|
||||||
@@ -60,8 +59,8 @@ class Settings(BaseSettings):
|
|||||||
# ===================================
|
# ===================================
|
||||||
SMTP_HOST: str = Field(default="localhost")
|
SMTP_HOST: str = Field(default="localhost")
|
||||||
SMTP_PORT: int = Field(default=587)
|
SMTP_PORT: int = Field(default=587)
|
||||||
SMTP_USER: Optional[str] = Field(default=None)
|
SMTP_USER: str | None = Field(default=None)
|
||||||
SMTP_PASSWORD: Optional[str] = Field(default=None)
|
SMTP_PASSWORD: str | None = Field(default=None)
|
||||||
SMTP_USE_TLS: bool = Field(default=True)
|
SMTP_USE_TLS: bool = Field(default=True)
|
||||||
SMTP_USE_SSL: bool = Field(default=False)
|
SMTP_USE_SSL: bool = Field(default=False)
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ class Settings(BaseSettings):
|
|||||||
UPLOAD_PATH: str = Field(default="/app/uploads")
|
UPLOAD_PATH: str = Field(default="/app/uploads")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ALLOWED_FILE_EXTENSIONS(self) -> List[str]:
|
def ALLOWED_FILE_EXTENSIONS(self) -> list[str]:
|
||||||
"""Parse the comma-separated file extensions."""
|
"""Parse the comma-separated file extensions."""
|
||||||
return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")]
|
return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")]
|
||||||
|
|
||||||
@@ -102,7 +101,7 @@ class Settings(BaseSettings):
|
|||||||
# ===================================
|
# ===================================
|
||||||
LOG_LEVEL: str = Field(default="INFO")
|
LOG_LEVEL: str = Field(default="INFO")
|
||||||
LOG_FORMAT: str = Field(default="json")
|
LOG_FORMAT: str = Field(default="json")
|
||||||
LOG_FILE: Optional[str] = Field(default=None)
|
LOG_FILE: str | None = Field(default=None)
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# FRONTEND URLS
|
# FRONTEND URLS
|
||||||
|
|||||||
20
backend/app/core/limiter.py
Normal file
20
backend/app/core/limiter.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Rate Limiter - ServiceManagerWeb
|
||||||
|
|
||||||
|
Configura slowapi con Redis como storage backend.
|
||||||
|
Respeta settings.RATE_LIMIT_ENABLED: si está desactivado usa memoria
|
||||||
|
y el limiter queda en modo noop (enabled=False).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
limiter = Limiter(
|
||||||
|
key_func=get_remote_address,
|
||||||
|
storage_uri=settings.REDIS_URL if settings.RATE_LIMIT_ENABLED else "memory://",
|
||||||
|
enabled=settings.RATE_LIMIT_ENABLED,
|
||||||
|
)
|
||||||
@@ -9,6 +9,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from slowapi import _rate_limit_exceeded_handler
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
import structlog
|
import structlog
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -31,6 +34,7 @@ from app.api.v1.router import api_router
|
|||||||
from app.middleware.tenant import TenantMiddleware
|
from app.middleware.tenant import TenantMiddleware
|
||||||
from app.middleware.correlation_id import CorrelationIDMiddleware
|
from app.middleware.correlation_id import CorrelationIDMiddleware
|
||||||
from app.core.cache import cache
|
from app.core.cache import cache
|
||||||
|
from app.core.limiter import limiter
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
setup_logging()
|
setup_logging()
|
||||||
@@ -70,6 +74,11 @@ app = FastAPI(
|
|||||||
openapi_url=f"/{settings.API_VERSION}/openapi.json"
|
openapi_url=f"/{settings.API_VERSION}/openapi.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SlowAPI rate limiting
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
app.add_middleware(SlowAPIMiddleware)
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# MIDDLEWARE
|
# MIDDLEWARE
|
||||||
# ===================================
|
# ===================================
|
||||||
@@ -87,8 +96,14 @@ if settings.is_production():
|
|||||||
"X-Correlation-ID",
|
"X-Correlation-ID",
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
cors_allow_methods = ["*"]
|
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||||
cors_allow_headers = ["*"]
|
cors_allow_headers = [
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"X-Tenant-ID",
|
||||||
|
"X-Tenant-Slug",
|
||||||
|
"X-Correlation-ID",
|
||||||
|
]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
|||||||
"/v1/auth/refresh",
|
"/v1/auth/refresh",
|
||||||
"/api/v1/auth/logout",
|
"/api/v1/auth/logout",
|
||||||
"/v1/auth/logout",
|
"/v1/auth/logout",
|
||||||
|
"/api/v1/auth/me",
|
||||||
|
"/v1/auth/me",
|
||||||
"/api/v1/auth/forgot-password",
|
"/api/v1/auth/forgot-password",
|
||||||
"/v1/auth/forgot-password",
|
"/v1/auth/forgot-password",
|
||||||
"/api/v1/auth/reset-password",
|
"/api/v1/auth/reset-password",
|
||||||
|
|||||||
63
backend/app/models/roles.py
Normal file
63
backend/app/models/roles.py
Normal 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, [])
|
||||||
|
|
||||||
@@ -27,6 +27,24 @@ class UserRole(str, enum.Enum):
|
|||||||
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
|
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
|
||||||
CLIENT_USER = "CLIENT_USER" # Usuario final 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):
|
class User(Base):
|
||||||
"""Modelo de Usuario."""
|
"""Modelo de Usuario."""
|
||||||
@@ -113,11 +131,8 @@ class User(Base):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_client(self) -> bool:
|
def is_client(self) -> bool:
|
||||||
"""Check if user is a client."""
|
"""Check if user is a client (rol acotado al propio tenant)."""
|
||||||
return self.role in [
|
return self.role.is_client
|
||||||
UserRole.CLIENT_ADMIN,
|
|
||||||
UserRole.CLIENT_USER
|
|
||||||
]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_manage_users(self) -> bool:
|
def can_manage_users(self) -> bool:
|
||||||
@@ -125,7 +140,7 @@ class User(Base):
|
|||||||
return self.role in [
|
return self.role in [
|
||||||
UserRole.ADMIN,
|
UserRole.ADMIN,
|
||||||
UserRole.SUPPORT_MANAGER,
|
UserRole.SUPPORT_MANAGER,
|
||||||
UserRole.CLIENT_ADMIN
|
UserRole.CLIENT_ADMIN,
|
||||||
]
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -134,7 +149,7 @@ class User(Base):
|
|||||||
return self.role in [
|
return self.role in [
|
||||||
UserRole.ADMIN,
|
UserRole.ADMIN,
|
||||||
UserRole.SUPPORT_MANAGER,
|
UserRole.SUPPORT_MANAGER,
|
||||||
UserRole.AGENT
|
UserRole.AGENT,
|
||||||
]
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
170
backend/app/services/ticket_service.py
Normal file
170
backend/app/services/ticket_service.py
Normal 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)
|
||||||
373
backend/app/tests/conftest.py
Normal file
373
backend/app/tests/conftest.py
Normal 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
|
||||||
137
backend/app/tests/test_smoke.py
Normal file
137
backend/app/tests/test_smoke.py
Normal 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"
|
||||||
123
backend/app/tests/test_tenant_isolation.py
Normal file
123
backend/app/tests/test_tenant_isolation.py
Normal 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
104
backend/auth_backup.ts
Normal 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
6
backend/check_lines.py
Normal 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
14
backend/check_user.py
Normal 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
16
backend/fix_response.py
Normal 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
18
backend/fix_syntax.py
Normal 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
28
backend/fix_users.py
Normal 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
60
backend/fix_users2.py
Normal 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
36
backend/fix_users3.py
Normal 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
38
backend/fix_users4.py
Normal 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")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""add_ticket_indexes
|
||||||
|
|
||||||
|
Revision ID: b7c8d9e0f1a2
|
||||||
|
Revises: fix_client_timestamps
|
||||||
|
Create Date: 2026-03-03 00:00:00.000000
|
||||||
|
|
||||||
|
Agrega índices a la tabla tickets para optimizar queries frecuentes:
|
||||||
|
- idx_tickets_status → filtros por estado
|
||||||
|
- idx_tickets_priority → filtros por prioridad
|
||||||
|
- idx_tickets_assigned_to → tickets por agente asignado
|
||||||
|
- idx_tickets_tenant_status → compuesto multi-tenant (tenant_id, status)
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b7c8d9e0f1a2'
|
||||||
|
down_revision = 'fix_client_timestamps'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets (status)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets (priority)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to "
|
||||||
|
"ON tickets (assigned_to) WHERE assigned_to IS NOT NULL"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tickets_tenant_status "
|
||||||
|
"ON tickets (tenant_id, status)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_tickets_tenant_status")
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_tickets_assigned_to")
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_tickets_priority")
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_tickets_status")
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -31,6 +31,7 @@ pyotp==2.9.0 # TOTP/2FA support
|
|||||||
# ===================================
|
# ===================================
|
||||||
celery==5.3.4
|
celery==5.3.4
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
|
slowapi==0.1.9 # Rate limiting middleware
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# EMAIL
|
# EMAIL
|
||||||
|
|||||||
11
backend/show_context.py
Normal file
11
backend/show_context.py
Normal 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
6
check_lines.py
Normal 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
14
check_user.py
Normal 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())
|
||||||
@@ -13,9 +13,9 @@ services:
|
|||||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- 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:
|
ports:
|
||||||
- "5432:5432"
|
- "5433:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -32,7 +32,7 @@ services:
|
|||||||
container_name: servicemanager-redis
|
container_name: servicemanager-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: redis-server --appendonly yes
|
command: redis-server --appendonly yes
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
@@ -215,7 +215,7 @@ services:
|
|||||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- uploads_data:/var/www/uploads:ro
|
- uploads_data:/var/www/uploads:ro
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "8088:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- frontend-client
|
- frontend-client
|
||||||
|
|||||||
67
fix_login.py
Normal file
67
fix_login.py
Normal 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
23
fix_ratelimit.py
Normal 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
16
fix_response.py
Normal 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
18
fix_syntax.py
Normal 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
28
fix_users.py
Normal 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
60
fix_users2.py
Normal 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
36
fix_users3.py
Normal 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
38
fix_users4.py
Normal 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")
|
||||||
11
frontend-client/src/app.d.ts
vendored
Normal file
11
frontend-client/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { User } from '$lib/stores/auth';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="es">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#3b82f6" />
|
<meta name="theme-color" content="#3b82f6" />
|
||||||
|
|
||||||
|
|||||||
37
frontend-client/src/hooks.server.ts
Normal file
37
frontend-client/src/hooks.server.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
@@ -29,15 +29,17 @@ const initialState: AppState = {
|
|||||||
// API helper function
|
// API helper function
|
||||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||||
const authState = get(auth);
|
const authState = get(auth);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-App': 'client',
|
||||||
|
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
|
||||||
|
...(options.headers as Record<string, string> ?? {})
|
||||||
|
};
|
||||||
|
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
|
||||||
const response = await fetch(`/api/v1${endpoint}`, {
|
const response = await fetch(`/api/v1${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
credentials: 'include',
|
||||||
'Content-Type': 'application/json',
|
headers
|
||||||
'Authorization': `Bearer ${authState.token}`,
|
|
||||||
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { Writable } from 'svelte/store';
|
import type { Writable } from 'svelte/store';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
// Types
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -13,129 +11,94 @@ export interface User {
|
|||||||
is_two_factor_enabled: boolean;
|
is_two_factor_enabled: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
tenant_slug: string;
|
tenant_slug: string;
|
||||||
totp_code?: string;
|
totp_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial state
|
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create auth store
|
|
||||||
function createAuthStore() {
|
function createAuthStore() {
|
||||||
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
|
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
|
||||||
|
let _state = initialState;
|
||||||
|
subscribe(s => { _state = s; });
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
|
init: async () => {
|
||||||
// Initialize auth from localStorage
|
|
||||||
init: () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const token = localStorage.getItem('auth_token');
|
try {
|
||||||
const user = localStorage.getItem('auth_user');
|
const response = await fetch('/api/v1/auth/me', {
|
||||||
|
credentials: 'include',
|
||||||
if (token && user) {
|
headers: { 'X-App': 'client', 'X-Tenant-Slug': 'aduanasoft' }
|
||||||
try {
|
});
|
||||||
const parsedUser = JSON.parse(user);
|
if (response.ok) {
|
||||||
set({
|
const user = await response.json();
|
||||||
user: parsedUser,
|
set({ user, token: null, isAuthenticated: true, isLoading: false });
|
||||||
token,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing stored auth data:', error);
|
|
||||||
localStorage.removeItem('auth_token');
|
|
||||||
localStorage.removeItem('auth_user');
|
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Login
|
|
||||||
login: async (credentials: LoginRequest): Promise<void> => {
|
login: async (credentials: LoginRequest): Promise<void> => {
|
||||||
update(state => ({ ...state, isLoading: true }));
|
update(state => ({ ...state, isLoading: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/login', {
|
const response = await fetch('/api/v1/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-Slug': credentials.tenant_slug,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(credentials)
|
body: JSON.stringify(credentials)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.detail || 'Login failed');
|
throw new Error(error.detail || 'Login failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: LoginResponse = await response.json();
|
const data: LoginResponse = await response.json();
|
||||||
|
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
|
||||||
// Store auth data
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('auth_token', data.access_token);
|
|
||||||
localStorage.setItem('auth_user', JSON.stringify(data.user));
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
|
||||||
user: data.user,
|
|
||||||
token: data.access_token,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
update(state => ({ ...state, isLoading: false }));
|
update(state => ({ ...state, isLoading: false }));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
logout: async () => {
|
||||||
// Logout
|
try {
|
||||||
logout: () => {
|
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') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('auth_token');
|
|
||||||
localStorage.removeItem('auth_user');
|
|
||||||
// Immediate redirect after cleanup
|
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
set(initialState);
|
|
||||||
},
|
},
|
||||||
|
updateUser: (user: User) => { update(state => ({ ...state, user })); },
|
||||||
// Update user data
|
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
|
||||||
updateUser: (user: User) => {
|
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
|
||||||
update(state => ({ ...state, user }));
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Set loading state
|
|
||||||
setLoading: (isLoading: boolean) => {
|
|
||||||
update(state => ({ ...state, isLoading }));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const auth = createAuthStore();
|
export const auth = createAuthStore();
|
||||||
104
frontend-client/src/lib/stores/auth.ts.bak
Normal file
104
frontend-client/src/lib/stores/auth.ts.bak
Normal 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();
|
||||||
@@ -80,18 +80,22 @@ const initialState: TicketsState = {
|
|||||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||||
const authState = get(auth);
|
const authState = get(auth);
|
||||||
|
|
||||||
if (!authState.token || !authState.user) {
|
if (!authState.user) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-App': 'client',
|
||||||
|
...(options.headers as Record<string, string>)
|
||||||
|
};
|
||||||
|
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
|
||||||
|
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
|
||||||
|
|
||||||
const response = await fetch(`/api/v1${endpoint}`, {
|
const response = await fetch(`/api/v1${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
credentials: 'include',
|
||||||
'Content-Type': 'application/json',
|
headers
|
||||||
'Authorization': `Bearer ${authState.token}`,
|
|
||||||
'X-Tenant-ID': authState.user.tenant_id,
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -259,16 +263,18 @@ function createTicketsStore() {
|
|||||||
|
|
||||||
const authState = get(auth);
|
const authState = get(auth);
|
||||||
|
|
||||||
if (!authState.token || !authState.user) {
|
if (!authState.user) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadHeaders: Record<string, string> = { 'X-App': 'client' };
|
||||||
|
if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`;
|
||||||
|
if (authState.user.tenant_id) uploadHeaders['X-Tenant-ID'] = authState.user.tenant_id;
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
|
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
'Authorization': `Bearer ${authState.token}`,
|
headers: uploadHeaders,
|
||||||
'X-Tenant-ID': authState.user.tenant_id
|
|
||||||
},
|
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -338,16 +344,18 @@ function createTicketsStore() {
|
|||||||
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
|
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
|
||||||
const authState = get(auth);
|
const authState = get(auth);
|
||||||
|
|
||||||
if (!authState.token || !authState.user) {
|
if (!authState.user) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dlHeaders: Record<string, string> = { 'X-App': 'client' };
|
||||||
|
if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`;
|
||||||
|
if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id;
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
|
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
'Authorization': `Bearer ${authState.token}`,
|
headers: dlHeaders
|
||||||
'X-Tenant-ID': authState.user.tenant_id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
116
frontend-client/src/lib/utils/api.ts
Normal file
116
frontend-client/src/lib/utils/api.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { auth } from '$lib/stores/auth';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1';
|
||||||
|
interface RequestOptions extends RequestInit {
|
||||||
|
params?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const { params, ...init } = options;
|
||||||
|
|
||||||
|
let url = `${API_BASE}${endpoint}`;
|
||||||
|
if (params) {
|
||||||
|
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()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authState = get(auth);
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
|
||||||
|
if (authState.token) {
|
||||||
|
headers.set('Authorization', `Bearer ${authState.token}`);
|
||||||
|
}
|
||||||
|
if (authState.user?.tenant_id && !headers.has('X-Tenant-ID')) {
|
||||||
|
headers.set('X-Tenant-ID', authState.user.tenant_id);
|
||||||
|
}
|
||||||
|
if (!headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
credentials: 'include',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || `API error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(endpoint: string, filename: string): Promise<void> {
|
||||||
|
const authState = get(auth);
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
if (authState.token) {
|
||||||
|
headers.set('Authorization', `Bearer ${authState.token}`);
|
||||||
|
}
|
||||||
|
if (authState.user?.tenant_id) {
|
||||||
|
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',
|
||||||
|
credentials: 'include',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || `Download error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
};
|
||||||
7
frontend-client/src/routes/+layout.server.ts
Normal file
7
frontend-client/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user ?? null
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -9,24 +9,34 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
auth.init();
|
if (data.user && !$auth.isAuthenticated) {
|
||||||
|
auth.setUser(data.user);
|
||||||
|
}
|
||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Guard reactivo global: redirige a /login si no está autenticado en rutas protegidas
|
// Guard reactivo global: redirige a /login si no está autenticado en rutas protegidas
|
||||||
|
const publicRoutes = ['/login', '/register', '/forgot-password', '/reset-password'];
|
||||||
$: if (browser && mounted && !$auth.isAuthenticated &&
|
$: if (browser && mounted && !$auth.isAuthenticated &&
|
||||||
!$page.url.pathname.startsWith('/login') &&
|
!publicRoutes.some(r => $page.url.pathname.startsWith(r))) {
|
||||||
!$page.url.pathname.startsWith('/register')) {
|
|
||||||
goto('/login');
|
goto('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
$: showHeader = !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/register');
|
$: showHeader = !publicRoutes.some(r => $page.url.pathname.startsWith(r));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50 font-sans">
|
<div class="min-h-screen bg-gray-50 font-sans">
|
||||||
|
{#if !mounted}
|
||||||
|
<!-- Esperando inicialización de sesión -->
|
||||||
|
<div class="flex items-center justify-center min-h-screen bg-gray-50">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
{#if showHeader}
|
{#if showHeader}
|
||||||
<Header />
|
<Header />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -39,6 +49,7 @@
|
|||||||
<footer class="py-4 text-center border-t border-gray-200 bg-white">
|
<footer class="py-4 text-center border-t border-gray-200 bg-white">
|
||||||
<p class="text-xs text-gray-400">ServiceManagerWeb v1.9.0 · © 2026 Aduanasoft</p>
|
<p class="text-xs text-gray-400">ServiceManagerWeb v1.9.0 · © 2026 Aduanasoft</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Toast notifications -->
|
<!-- Toast notifications -->
|
||||||
{#each $toast.toasts as toastMessage (toastMessage.id)}
|
{#each $toast.toasts as toastMessage (toastMessage.id)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
let email = '';
|
let email = '';
|
||||||
let password = '';
|
let password = '';
|
||||||
let tenantSlug = 'aduanasoft-demo';
|
let tenantSlug = 'ventas';
|
||||||
let totpCode = '';
|
let totpCode = '';
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
let showTwoFactor = false;
|
let showTwoFactor = false;
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
await auth.login({
|
await auth.login({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
tenant_slug: tenantSlug.trim() || 'aduanasoft-demo',
|
tenant_slug: tenantSlug.trim() || 'ventas',
|
||||||
totp_code: totpCode || undefined
|
totp_code: totpCode || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
||||||
goto('/');
|
goto('/');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
@@ -46,9 +46,9 @@
|
|||||||
// Check if 2FA is required
|
// Check if 2FA is required
|
||||||
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
||||||
showTwoFactor = true;
|
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 {
|
} else {
|
||||||
errorMessage = error.message || 'Error al iniciar sesión';
|
errorMessage = error.message || 'Error al iniciar sesión';
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,8 +96,8 @@
|
|||||||
de Servicios de TI
|
de Servicios de TI
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md">
|
<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
|
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.
|
reciba asistencia especializada para garantizar la continuidad de su operación.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
<div
|
<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"
|
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}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -135,13 +135,13 @@
|
|||||||
<!-- Email Input -->
|
<!-- Email Input -->
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="email" class="block text-sm font-semibold text-gray-700"
|
<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="relative group">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Icon
|
<Icon
|
||||||
name="mail"
|
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>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -160,13 +160,13 @@
|
|||||||
<!-- Password Input -->
|
<!-- Password Input -->
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="password" class="block text-sm font-semibold text-gray-700"
|
<label for="password" class="block text-sm font-semibold text-gray-700"
|
||||||
>Contraseña</label
|
>Contraseña</label
|
||||||
>
|
>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Icon
|
<Icon
|
||||||
name="lock"
|
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>
|
</div>
|
||||||
{#if showPassword}
|
{#if showPassword}
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
bind:value={password}
|
bind:value={password}
|
||||||
on:keydown={handleKeyDown}
|
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"
|
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
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
bind:value={password}
|
bind:value={password}
|
||||||
on:keydown={handleKeyDown}
|
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"
|
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
|
required
|
||||||
disabled={isLoading}
|
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"
|
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)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,25 +216,26 @@
|
|||||||
>Recordar en este equipo</label
|
>Recordar en este equipo</label
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<button
|
||||||
href="/forgot-password"
|
type="button"
|
||||||
class="text-sm font-medium text-blue-600 hover:text-blue-500"
|
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
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- 2FA Input -->
|
<!-- 2FA Input -->
|
||||||
<div class="space-y-4 animate-slide-up">
|
<div class="space-y-4 animate-slide-up">
|
||||||
<label for="code" class="block text-sm font-medium text-gray-700 text-center"
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<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>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="code"
|
id="code"
|
||||||
@@ -258,7 +259,7 @@
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{#if 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...
|
Procesando...
|
||||||
{:else}
|
{:else}
|
||||||
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
|
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
|
||||||
@@ -267,7 +268,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 text-center text-xs text-gray-400">
|
<div class="mt-8 text-center text-xs text-gray-400">
|
||||||
© 2026 Aduanasoft. Acceso exclusivo autorizado.
|
© 2026 Aduanasoft. Acceso exclusivo autorizado.
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,11 +38,14 @@
|
|||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'X-App': 'client',
|
||||||
|
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||||
|
};
|
||||||
|
if ($auth.token) headers['Authorization'] = `Bearer ${$auth.token}`;
|
||||||
const response = await fetch('/api/v1/client-profile/', {
|
const response = await fetch('/api/v1/client-profile/', {
|
||||||
headers: {
|
credentials: 'include',
|
||||||
Authorization: `Bearer ${$auth.token}`,
|
headers
|
||||||
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error((await response.json()).detail);
|
if (!response.ok) throw new Error((await response.json()).detail);
|
||||||
profile = await response.json();
|
profile = await response.json();
|
||||||
@@ -62,13 +65,16 @@
|
|||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
try {
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-App': 'client',
|
||||||
|
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||||
|
};
|
||||||
|
if ($auth.token) headers['Authorization'] = `Bearer ${$auth.token}`;
|
||||||
const response = await fetch('/api/v1/client-profile/', {
|
const response = await fetch('/api/v1/client-profile/', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
'Content-Type': 'application/json',
|
headers,
|
||||||
Authorization: `Bearer ${$auth.token}`,
|
|
||||||
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
|
||||||
},
|
|
||||||
body: JSON.stringify(form)
|
body: JSON.stringify(form)
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error((await response.json()).detail);
|
if (!response.ok) throw new Error((await response.json()).detail);
|
||||||
|
|||||||
@@ -34,7 +34,11 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/2fa/setup', {
|
const response = await fetch('/api/v1/auth/2fa/setup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${$auth.token}` }
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'X-App': 'client',
|
||||||
|
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error((await response.json()).detail);
|
if (!response.ok) throw new Error((await response.json()).detail);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -57,7 +61,12 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/2fa/enable', {
|
const response = await fetch('/api/v1/auth/2fa/enable', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` },
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-App': 'client',
|
||||||
|
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||||
|
},
|
||||||
body: JSON.stringify({ totp_code: totpSetupCode })
|
body: JSON.stringify({ totp_code: totpSetupCode })
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error((await response.json()).detail);
|
if (!response.ok) throw new Error((await response.json()).detail);
|
||||||
@@ -84,7 +93,12 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/2fa/disable', {
|
const response = await fetch('/api/v1/auth/2fa/disable', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` },
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-App': 'client',
|
||||||
|
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||||
|
},
|
||||||
body: JSON.stringify({ totp_code: disableTotpCode })
|
body: JSON.stringify({ totp_code: disableTotpCode })
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error((await response.json()).detail);
|
if (!response.ok) throw new Error((await response.json()).detail);
|
||||||
@@ -157,16 +171,20 @@
|
|||||||
|
|
||||||
async function loadBusinessProfile() {
|
async function loadBusinessProfile() {
|
||||||
try {
|
try {
|
||||||
if (!$auth.token || !$auth.user) {
|
if (!$auth.user) {
|
||||||
console.warn('Usuario no autenticado');
|
console.warn('Usuario no autenticado');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _lpHeaders: Record<string, string> = {
|
||||||
|
'X-App': 'client',
|
||||||
|
'X-Tenant-ID': $auth.user.tenant_id
|
||||||
|
};
|
||||||
|
if ($auth.token) _lpHeaders['Authorization'] = `Bearer ${$auth.token}`;
|
||||||
|
|
||||||
const response = await fetch('/api/v1/client-profile/', {
|
const response = await fetch('/api/v1/client-profile/', {
|
||||||
headers: {
|
credentials: 'include',
|
||||||
Authorization: `Bearer ${$auth.token}`,
|
headers: _lpHeaders
|
||||||
'X-Tenant-ID': $auth.user.tenant_id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -267,9 +285,11 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/profile', {
|
const response = await fetch('/api/v1/auth/profile', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${$auth.token}`
|
'X-App': 'client',
|
||||||
|
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
first_name: firstName.trim(),
|
first_name: firstName.trim(),
|
||||||
@@ -300,9 +320,11 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/change-password', {
|
const response = await fetch('/api/v1/auth/change-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${$auth.token}`
|
'X-App': 'client',
|
||||||
|
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
current_password: currentPassword,
|
current_password: currentPassword,
|
||||||
@@ -349,13 +371,16 @@
|
|||||||
profileData.credit_limit = parseFloat(profileData.credit_limit);
|
profileData.credit_limit = parseFloat(profileData.credit_limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _bpHeaders: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-App': 'client',
|
||||||
|
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||||
|
};
|
||||||
|
if ($auth.token) _bpHeaders['Authorization'] = `Bearer ${$auth.token}`;
|
||||||
const response = await fetch('/api/v1/client-profile/', {
|
const response = await fetch('/api/v1/client-profile/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
'Content-Type': 'application/json',
|
headers: _bpHeaders,
|
||||||
Authorization: `Bearer ${$auth.token}`,
|
|
||||||
'X-Tenant-ID': $auth.user.tenant_id
|
|
||||||
},
|
|
||||||
body: JSON.stringify(profileData)
|
body: JSON.stringify(profileData)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
BIN
frontend-client/static/favicon.png
Normal file
BIN
frontend-client/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -1,38 +1,35 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
watch: {
|
watch: {
|
||||||
usePolling: true,
|
usePolling: true,
|
||||||
interval: 500
|
interval: 500
|
||||||
},
|
},
|
||||||
// HMR: el browser llega al contenedor en el mismo puerto 3000
|
hmr: {
|
||||||
hmr: {
|
host: 'localhost',
|
||||||
host: 'localhost',
|
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
|
||||||
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
|
},
|
||||||
},
|
fs: {
|
||||||
// Permitir que Vite sirva archivos del filesystem del contenedor
|
allow: ['/app', '.'],
|
||||||
fs: {
|
strict: false
|
||||||
allow: ['/app', '.'],
|
},
|
||||||
strict: false
|
proxy: {
|
||||||
},
|
'/api': {
|
||||||
proxy: {
|
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
|
||||||
'/api': {
|
changeOrigin: true,
|
||||||
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
|
rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
changeOrigin: true,
|
}
|
||||||
rewrite: (path) => path.replace(/^\/api/, '')
|
}
|
||||||
}
|
},
|
||||||
}
|
preview: {
|
||||||
},
|
port: 3000,
|
||||||
preview: {
|
host: '0.0.0.0'
|
||||||
port: 3000,
|
},
|
||||||
host: '0.0.0.0'
|
build: {
|
||||||
},
|
target: 'esnext'
|
||||||
build: {
|
}
|
||||||
target: 'esnext'
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
11
frontend-internal/src/app.d.ts
vendored
Normal file
11
frontend-internal/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { InternalUser } from '$lib/stores/auth';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user: InternalUser | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="description" content="ServiceManager - Mesa de Ayuda Empresarial - Portal Interno" />
|
<meta name="description" content="ServiceManager - Mesa de Ayuda Empresarial - Portal Interno" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|||||||
19
frontend-internal/src/hooks.server.ts
Normal file
19
frontend-internal/src/hooks.server.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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': 'internal' }
|
||||||
|
});
|
||||||
|
event.locals.user = response.ok ? await response.json() : null;
|
||||||
|
} catch {
|
||||||
|
event.locals.user = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
event.locals.user = null;
|
||||||
|
}
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import type { Writable } from 'svelte/store';
|
import type { Writable } from 'svelte/store';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
// Types
|
export interface User {
|
||||||
export interface InternalUser {
|
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_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_active: boolean;
|
||||||
is_two_factor_enabled: boolean;
|
is_two_factor_enabled: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
tenant_id: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
user: InternalUser | null;
|
user: User | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
refreshToken: string | null;
|
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
@@ -25,190 +24,90 @@ export interface AuthState {
|
|||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
tenant_slug: string;
|
tenant_slug?: string;
|
||||||
totp_code?: string;
|
totp_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
|
||||||
token_type: string;
|
token_type: string;
|
||||||
expires_in: number;
|
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 = {
|
const initialState: AuthState = {
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
refreshToken: null,
|
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create auth store
|
|
||||||
function createAuthStore() {
|
function createAuthStore() {
|
||||||
const { subscribe, set, update } = writable<AuthState>(initialState);
|
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
|
||||||
|
let _state = initialState;
|
||||||
|
subscribe(s => { _state = s; });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
|
init: async () => {
|
||||||
// Initialize auth from localStorage
|
|
||||||
init: () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const token = localStorage.getItem('internal_auth_token');
|
try {
|
||||||
const refreshToken = localStorage.getItem('internal_auth_refresh_token');
|
const response = await fetch('/api/v1/auth/me', {
|
||||||
const user = localStorage.getItem('internal_auth_user');
|
credentials: 'include',
|
||||||
|
headers: { 'X-App': 'client' }
|
||||||
if (token && user) {
|
});
|
||||||
try {
|
if (response.ok) {
|
||||||
const parsedUser = JSON.parse(user);
|
const user = await response.json();
|
||||||
set({
|
set({ user, token: null, isAuthenticated: true, isLoading: false });
|
||||||
user: parsedUser,
|
|
||||||
token,
|
|
||||||
refreshToken: refreshToken || null,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing stored auth data:', error);
|
|
||||||
localStorage.removeItem('internal_auth_token');
|
|
||||||
localStorage.removeItem('internal_auth_refresh_token');
|
|
||||||
localStorage.removeItem('internal_auth_user');
|
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Login
|
|
||||||
login: async (credentials: LoginRequest): Promise<void> => {
|
login: async (credentials: LoginRequest): Promise<void> => {
|
||||||
update(state => ({ ...state, isLoading: true }));
|
update(state => ({ ...state, isLoading: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/login', {
|
const response = await fetch('/api/v1/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'X-App': 'client',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(credentials)
|
body: JSON.stringify(credentials)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.detail || 'Login failed');
|
throw new Error(error.detail || 'Login failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: LoginResponse = await response.json();
|
const data: LoginResponse = await response.json();
|
||||||
|
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
|
||||||
// Store auth data
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('internal_auth_token', data.access_token);
|
|
||||||
if (data.refresh_token) {
|
|
||||||
localStorage.setItem('internal_auth_refresh_token', data.refresh_token);
|
|
||||||
}
|
|
||||||
localStorage.setItem('internal_auth_user', JSON.stringify(data.user));
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
|
||||||
user: data.user,
|
|
||||||
token: data.access_token,
|
|
||||||
refreshToken: data.refresh_token,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
update(state => ({ ...state, isLoading: false }));
|
update(state => ({ ...state, isLoading: false }));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
logout: async () => {
|
||||||
// Refresh Session
|
try {
|
||||||
refreshSession: async (): Promise<void> => {
|
const token = _state.token;
|
||||||
// Need to get current state to access refresh token, logic simplified
|
const slug = _state.user?.tenant_slug || _state.user?.tenant_id || '';
|
||||||
let currentRefreshToken: string | null = null;
|
await fetch('/api/v1/auth/logout', {
|
||||||
if (typeof window !== 'undefined') {
|
method: 'POST',
|
||||||
currentRefreshToken = localStorage.getItem('internal_auth_refresh_token');
|
credentials: 'include',
|
||||||
}
|
headers: {
|
||||||
|
'X-App': 'client',
|
||||||
if (!currentRefreshToken) {
|
'X-Tenant-Slug': slug,
|
||||||
throw new Error("No refresh token available");
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
}
|
}
|
||||||
|
});
|
||||||
update (state => ({ ...state, isLoading: true }));
|
} catch {}
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/auth/refresh', {
|
|
||||||
method: 'POST',
|
|
||||||
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 token in storage and state
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('internal_auth_token', data.access_token);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(state => ({
|
|
||||||
...state,
|
|
||||||
token: data.access_token,
|
|
||||||
isLoading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
update(state => ({ ...state, isLoading: false }));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Logout
|
|
||||||
logout: () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.removeItem('internal_auth_token');
|
|
||||||
localStorage.removeItem('internal_auth_refresh_token');
|
|
||||||
localStorage.removeItem('internal_auth_user');
|
|
||||||
}
|
|
||||||
set(initialState);
|
set(initialState);
|
||||||
// Optional: Redirect to login
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
updateUser: (user: User) => { update(state => ({ ...state, user })); },
|
||||||
// Update user data
|
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
|
||||||
updateUser: (user: InternalUser) => {
|
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
|
||||||
update(state => ({ ...state, user }));
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('internal_auth_user', JSON.stringify(user));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Set loading state
|
|
||||||
setLoading: (isLoading: boolean) => {
|
|
||||||
update(state => ({ ...state, isLoading }));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,16 +24,8 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authState = get(auth);
|
const authState = get(auth);
|
||||||
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
|
const token = authState.token;
|
||||||
|
const tenantId = authState.user?.tenant_id ?? null;
|
||||||
// Resolve tenant_id from store or from the persisted user object in localStorage
|
|
||||||
let tenantId = authState.user?.tenant_id ?? null;
|
|
||||||
if (!tenantId && typeof window !== 'undefined') {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('internal_auth_user');
|
|
||||||
if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = new Headers(init.headers);
|
const headers = new Headers(init.headers);
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -45,17 +37,18 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
|
|||||||
if (!headers.has('Content-Type')) {
|
if (!headers.has('Content-Type')) {
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
}
|
}
|
||||||
|
// Identifica este frontend para que el backend use la cookie correcta
|
||||||
|
headers.set('X-App', 'internal');
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
|
credentials: 'include',
|
||||||
headers
|
headers
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
// Token expired or invalid
|
// Token expired or invalid
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('internal_auth_token');
|
|
||||||
localStorage.removeItem('internal_auth_user');
|
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
throw new Error('Unauthorized');
|
throw new Error('Unauthorized');
|
||||||
@@ -76,15 +69,8 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
|
|||||||
|
|
||||||
async function downloadFile(endpoint: string, filename: string): Promise<void> {
|
async function downloadFile(endpoint: string, filename: string): Promise<void> {
|
||||||
const authState = get(auth);
|
const authState = get(auth);
|
||||||
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
|
const token = authState.token;
|
||||||
|
const tenantId = authState.user?.tenant_id ?? null;
|
||||||
let tenantId = authState.user?.tenant_id ?? null;
|
|
||||||
if (!tenantId && typeof window !== 'undefined') {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('internal_auth_user');
|
|
||||||
if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -93,16 +79,16 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
|
|||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
headers.set('X-Tenant-ID', tenantId);
|
headers.set('X-Tenant-ID', tenantId);
|
||||||
}
|
}
|
||||||
|
headers.set('X-App', 'internal');
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
headers
|
headers
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('internal_auth_token');
|
|
||||||
localStorage.removeItem('internal_auth_user');
|
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
throw new Error('Unauthorized');
|
throw new Error('Unauthorized');
|
||||||
|
|||||||
7
frontend-internal/src/routes/+layout.server.ts
Normal file
7
frontend-internal/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user ?? null
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -10,11 +10,15 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
let sidebarOpen = false;
|
let sidebarOpen = false;
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
auth.init();
|
if (data.user && !$auth.isAuthenticated) {
|
||||||
|
auth.setUser(data.user);
|
||||||
|
}
|
||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,7 +33,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
{#if $auth.isAuthenticated}
|
{#if !mounted}
|
||||||
|
<!-- Esperando inicialización de sesión -->
|
||||||
|
<div class="flex items-center justify-center min-h-screen bg-gray-50">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
{:else if $auth.isAuthenticated}
|
||||||
<!-- Internal Layout with Sidebar -->
|
<!-- Internal Layout with Sidebar -->
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
|
|||||||
@@ -1,46 +1,46 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import { auth } from '$lib/stores/auth.js';
|
import { auth } from '$lib/stores/auth.js';
|
||||||
import { toast } from '$lib/stores/toast.js';
|
import { toast } from '$lib/stores/toast.js';
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
|
||||||
|
|
||||||
let email = '';
|
let email = '';
|
||||||
let password = '';
|
let password = '';
|
||||||
let totpCode = '';
|
let totpCode = '';
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
let showTwoFactor = false;
|
let showTwoFactor = false;
|
||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Redirect if already authenticated
|
// Redirect if already authenticated
|
||||||
if ($auth.isAuthenticated) {
|
if ($auth.isAuthenticated) {
|
||||||
goto('/');
|
goto('/');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
errorMessage = 'Por favor completa todos los campos';
|
errorMessage = 'Por favor completa todos los campos';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await auth.login({
|
await auth.login({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
tenant_slug: 'aduanasoft-demo',
|
tenant_slug: 'aduanasoft',
|
||||||
totp_code: totpCode || undefined
|
totp_code: totpCode || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
||||||
goto('/');
|
goto('/');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
|
|
||||||
// Check if 2FA is required
|
// Check if 2FA is required
|
||||||
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
||||||
showTwoFactor = true;
|
showTwoFactor = true;
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
handleLogin();
|
handleLogin();
|
||||||
@@ -61,44 +61,50 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Acceso Admin - ServiceManager</title>
|
<title>Acceso Admin - ServiceManager</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950 p-4 font-sans">
|
<div
|
||||||
<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">
|
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 -->
|
<!-- 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 -->
|
<!-- 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="relative z-10">
|
||||||
<div class="flex items-center space-x-3 mb-6">
|
<div class="flex items-center space-x-3 mb-6">
|
||||||
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
|
<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" />
|
<Icon name="server" className="w-6 h-6 text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
|
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-3xl font-bold tracking-tight mb-4">
|
<h1 class="text-3xl font-bold tracking-tight mb-4">Panel de Administración</h1>
|
||||||
Panel de Administración
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-400 text-sm leading-relaxed max-w-sm">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-10 mt-12">
|
<div class="relative z-10 mt-12">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
<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" />
|
<Icon name="check-circle" className="w-4 h-4 text-green-500" />
|
||||||
<span>System Status: Operational</span>
|
<span>System Status: Operational</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
<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" />
|
<Icon name="shield" className="w-4 h-4 text-blue-500" />
|
||||||
<span>256-bit Encryption Enabled</span>
|
<span>256-bit Encryption Enabled</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,106 +112,129 @@
|
|||||||
|
|
||||||
<!-- Right Side: Login Form -->
|
<!-- Right Side: Login Form -->
|
||||||
<div class="p-8 md:p-12 flex flex-col justify-center">
|
<div class="p-8 md:p-12 flex flex-col justify-center">
|
||||||
|
<div class="max-w-sm mx-auto w-full">
|
||||||
<div class="max-w-sm mx-auto w-full">
|
<div class="mb-8">
|
||||||
<div class="mb-8">
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
|
||||||
<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>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<form on:submit|preventDefault={handleLogin} class="space-y-5">
|
<form on:submit|preventDefault={handleLogin} class="space-y-5">
|
||||||
{#if errorMessage}
|
{#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">
|
<div
|
||||||
<Icon name="alert-triangle" class="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
|
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"
|
||||||
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
|
>
|
||||||
</div>
|
<Icon
|
||||||
{/if}
|
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}
|
{#if !showTwoFactor}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<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>
|
<label
|
||||||
<div class="relative group">
|
for="email"
|
||||||
<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">
|
class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1"
|
||||||
<Icon name="user" class="w-5 h-5" />
|
>Usuario / Correo</label
|
||||||
</div>
|
>
|
||||||
<input
|
<div class="relative group">
|
||||||
id="email"
|
<div
|
||||||
type="email"
|
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"
|
||||||
bind:value={email}
|
>
|
||||||
on:keydown={handleKeyDown}
|
<Icon name="user" className="w-5 h-5" />
|
||||||
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"
|
</div>
|
||||||
placeholder="admin@aduanasoft.com"
|
<input
|
||||||
required
|
id="email"
|
||||||
disabled={isLoading}
|
type="email"
|
||||||
/>
|
bind:value={email}
|
||||||
</div>
|
on:keydown={handleKeyDown}
|
||||||
</div>
|
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>
|
<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>
|
<label
|
||||||
<div class="relative group">
|
for="password"
|
||||||
<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">
|
class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1"
|
||||||
<Icon name="lock" class="w-5 h-5" />
|
>Clave de Acceso</label
|
||||||
</div>
|
>
|
||||||
<input
|
<div class="relative group">
|
||||||
id="password"
|
<div
|
||||||
type="password"
|
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"
|
||||||
bind:value={password}
|
>
|
||||||
on:keydown={handleKeyDown}
|
<Icon name="lock" className="w-5 h-5" />
|
||||||
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"
|
</div>
|
||||||
placeholder="••••••••••••"
|
<input
|
||||||
required
|
id="password"
|
||||||
disabled={isLoading}
|
type="password"
|
||||||
/>
|
bind:value={password}
|
||||||
</div>
|
on:keydown={handleKeyDown}
|
||||||
</div>
|
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"
|
||||||
</div>
|
placeholder="••••••••••••"
|
||||||
|
required
|
||||||
{:else}
|
disabled={isLoading}
|
||||||
<!-- 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">
|
</div>
|
||||||
<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>
|
||||||
<div class="relative">
|
</div>
|
||||||
<input
|
{:else}
|
||||||
id="code"
|
<!-- 2FA Input -->
|
||||||
type="text"
|
<div
|
||||||
bind:value={totpCode}
|
class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30"
|
||||||
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"
|
<label
|
||||||
placeholder="000000"
|
for="code"
|
||||||
maxlength="6"
|
class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center"
|
||||||
required
|
>Verificación de Seguridad</label
|
||||||
disabled={isLoading}
|
>
|
||||||
autofocus
|
<div class="relative">
|
||||||
/>
|
<input
|
||||||
</div>
|
id="code"
|
||||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-2 text-center">
|
type="text"
|
||||||
Consulte su dispositivo autenticador
|
bind:value={totpCode}
|
||||||
</p>
|
on:keydown={handleKeyDown}
|
||||||
</div>
|
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"
|
||||||
{/if}
|
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">
|
<div class="pt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<Icon name="loader" class="w-4 h-4 animate-spin mr-2" />
|
<Icon name="loader" className="w-4 h-4 animate-spin mr-2" />
|
||||||
Autenticando...
|
Autenticando...
|
||||||
{:else}
|
{:else}
|
||||||
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
|
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
|
<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>
|
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">
|
||||||
</div>
|
Aduanasoft Internal Systems © 2024
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,492 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { auth } from '$lib/stores/auth.js';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!$auth.isAuthenticated) goto('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
||||||
interface EndpointDef {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
||||||
path: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EndpointResult {
|
|
||||||
status: number | null;
|
|
||||||
ok: boolean | null;
|
|
||||||
ms: number | null;
|
|
||||||
error: string | null;
|
|
||||||
preview: string | null;
|
|
||||||
tested: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageDef {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
description: string;
|
|
||||||
roles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Backend Endpoints ───────────────────────────────────────────────────────
|
|
||||||
const GROUPS: { name: string; color: string; endpoints: EndpointDef[] }[] = [
|
|
||||||
{
|
|
||||||
name: 'Auth',
|
|
||||||
color: 'bg-purple-100 text-purple-800',
|
|
||||||
endpoints: [
|
|
||||||
{ id: 'auth-me', label: 'Perfil actual', method: 'GET', path: '/auth/me', description: 'Información del usuario autenticado' },
|
|
||||||
{ id: 'auth-refresh', label: 'Refrescar token', method: 'POST', path: '/auth/refresh', description: 'Renovar access token (POST)' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Health',
|
|
||||||
color: 'bg-green-100 text-green-800',
|
|
||||||
endpoints: [
|
|
||||||
{ id: 'health', label: 'Health Check', method: 'GET', path: '/health', description: 'Estado general del sistema' },
|
|
||||||
{ id: 'health-details', label: 'Health Detallado', method: 'GET', path: '/health/detailed', description: 'Estado con detalle de dependencias' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tenants',
|
|
||||||
color: 'bg-blue-100 text-blue-800',
|
|
||||||
endpoints: [
|
|
||||||
{ id: 'tenants-list', label: 'Listar Tenants', method: 'GET', path: '/tenants/', description: 'Todos los tenants registrados' },
|
|
||||||
{ id: 'tenant-stats', label: 'Stats Tenant', method: 'GET', path: '/tenants/stats', description: 'Estadísticas globales de tenants' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Users',
|
|
||||||
color: 'bg-indigo-100 text-indigo-800',
|
|
||||||
endpoints: [
|
|
||||||
{ id: 'users-list', label: 'Listar Usuarios', method: 'GET', path: '/users/', description: 'Todos los usuarios del sistema' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tickets',
|
|
||||||
color: 'bg-orange-100 text-orange-800',
|
|
||||||
endpoints: [
|
|
||||||
{ id: 'tickets-list', label: 'Listar Tickets', method: 'GET', path: '/tickets/', description: 'Tickets con paginación' },
|
|
||||||
{ id: 'tickets-stats', label: 'Stats Tickets', method: 'GET', path: '/tickets/stats', description: 'Estadísticas de tickets' },
|
|
||||||
{ id: 'tickets-comments',label: 'Comentarios recientes', method: 'GET', path: '/tickets/comments/recent',description: 'Últimos comentarios' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Categories',
|
|
||||||
color: 'bg-amber-100 text-amber-800',
|
|
||||||
endpoints: [
|
|
||||||
{ id: 'cats-list', label: 'Listar Categorías', method: 'GET', path: '/categories/', description: 'Categorías de tickets' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Systems',
|
|
||||||
color: 'bg-gray-100 text-gray-800',
|
|
||||||
endpoints: [
|
|
||||||
{ id: 'sys-list', label: 'Listar Sistemas', method: 'GET', path: '/systems/', description: 'Sistemas soportados' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'SLA',
|
|
||||||
color: 'bg-teal-100 text-teal-800',
|
|
||||||
endpoints: [
|
|
||||||
{ id: 'sla-dashboard', label: 'Dashboard SLA', method: 'GET', path: '/sla/dashboard', description: 'Panel SLA principal' },
|
|
||||||
{ id: 'sla-compliance', label: 'SLA Compliance', method: 'GET', path: '/sla/compliance', description: 'Métricas de cumplimiento SLA' },
|
|
||||||
{ id: 'sla-at-risk', label: 'Tickets en Riesgo', method: 'GET', path: '/sla/at-risk', description: 'Tickets próximos a violar SLA' },
|
|
||||||
{ id: 'sla-violations', label: 'Violaciones SLA', method: 'GET', path: '/sla/violations', description: 'Tickets que violaron SLA' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Reports',
|
|
||||||
color: 'bg-pink-100 text-pink-800',
|
|
||||||
endpoints: [
|
|
||||||
{ id: 'rep-summary', label: 'Resumen General', method: 'GET', path: '/reports/summary', description: 'Resumen ejecutivo de reportes' },
|
|
||||||
{ id: 'rep-agents', label: 'Por Agente', method: 'GET', path: '/reports/agents', description: 'Rendimiento por agente' },
|
|
||||||
{ id: 'rep-categories', label: 'Por Categoría', method: 'GET', path: '/reports/categories', description: 'Distribución por categoría' },
|
|
||||||
{ id: 'rep-trends', label: 'Tendencias', method: 'GET', path: '/reports/trends', description: 'Tendencias temporales' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Audit',
|
|
||||||
color: 'bg-red-100 text-red-800',
|
|
||||||
endpoints: [
|
|
||||||
{ id: 'audit-logs', label: 'Logs de Auditoría', method: 'GET', path: '/audit/logs', description: 'Bitácora de acciones' },
|
|
||||||
{ id: 'audit-stats', label: 'Stats Auditoría', method: 'GET', path: '/audit/stats', description: 'Estadísticas de auditoría' },
|
|
||||||
{ id: 'audit-security', label: 'Análisis Seguridad', method: 'GET', path: '/audit/security/analysis',description: 'Análisis de amenazas de seguridad' },
|
|
||||||
{ id: 'audit-users', label: 'Actividad Usuarios', method: 'GET', path: '/audit/users', description: 'Actividad por usuario' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Client Profile',
|
|
||||||
color: 'bg-cyan-100 text-cyan-800',
|
|
||||||
endpoints: [
|
|
||||||
{ id: 'client-profile', label: 'Perfil Cliente', method: 'GET', path: '/client/profile', description: 'Perfil organización cliente' },
|
|
||||||
{ id: 'client-tickets', label: 'Tickets Cliente', method: 'GET', path: '/client/tickets', description: 'Tickets del cliente' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Frontend Pages ──────────────────────────────────────────────────────────
|
|
||||||
const FRONTEND_PAGES: PageDef[] = [
|
|
||||||
{ label: 'Dashboard', href: '/', description: 'Panel principal de administración', roles: ['todos'] },
|
|
||||||
{ label: 'Tickets', href: '/tickets', description: 'Gestión y listado de tickets', roles: ['ADMIN', 'SUPPORT_MANAGER', 'AGENT'] },
|
|
||||||
{ label: 'Clientes (Tenants)', href: '/tenants', description: 'Administración de organizaciones cliente', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
|
||||||
{ label: 'Usuarios', href: '/users', description: 'Gestión de usuarios internos', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
|
||||||
{ label: 'Categorías', href: '/categories', description: 'Categorías y SLA por área', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
|
||||||
{ label: 'Sistemas', href: '/systems', description: 'Catálogo de sistemas soportados', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
|
||||||
{ label: 'SLA Dashboard', href: '/sla', description: 'Monitoreo de SLAs y cumplimiento', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
|
||||||
{ label: 'SLA En Riesgo', href: '/sla/at-risk', description: 'Tickets próximos a violar SLA', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
|
||||||
{ label: 'SLA Violaciones', href: '/sla/violations', description: 'Historial de violaciones SLA', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
|
||||||
{ label: 'Reportes', href: '/reports', description: 'Reportes estadísticos e informes', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
|
||||||
{ label: 'Auditoría', href: '/audit', description: 'Bitácora de acciones del sistema', roles: ['ADMIN', 'AUDITOR'] },
|
|
||||||
{ label: 'Seguridad', href: '/audit/security', description: 'Análisis de amenazas y eventos de seguridad',roles: ['ADMIN'] },
|
|
||||||
{ label: 'Perfil', href: '/profile', description: 'Perfil y configuración de seguridad', roles: ['todos'] },
|
|
||||||
{ label: 'Rate Limits', href: '/rate-limits', description: 'Estado de rate limiting por IP', roles: ['ADMIN'] },
|
|
||||||
{ label: 'Reporte Endpoints', href: '/test-report', description: 'Esta misma página', roles: ['ADMIN'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────────────
|
|
||||||
let results: Record<string, EndpointResult> = {};
|
|
||||||
let isTesting = false;
|
|
||||||
let testingId: string | null = null;
|
|
||||||
let totalOk = 0;
|
|
||||||
let totalFail = 0;
|
|
||||||
let activeTab: 'endpoints' | 'pages' = 'endpoints';
|
|
||||||
|
|
||||||
// Init results
|
|
||||||
for (const group of GROUPS) {
|
|
||||||
for (const ep of group.endpoints) {
|
|
||||||
results[ep.id] = { status: null, ok: null, ms: null, error: null, preview: null, tested: false, loading: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getToken(): string | null {
|
|
||||||
return (typeof window !== 'undefined')
|
|
||||||
? localStorage.getItem('internal_auth_token')
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTenantId(): string | null {
|
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('internal_auth_user');
|
|
||||||
if (stored) return JSON.parse(stored)?.tenant_id ?? null;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testEndpoint(ep: EndpointDef) {
|
|
||||||
results[ep.id] = { ...results[ep.id], loading: true, tested: false };
|
|
||||||
results = results; // trigger reactivity
|
|
||||||
|
|
||||||
const token = getToken();
|
|
||||||
const tenantId = getTenantId();
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
if (tenantId) headers['X-Tenant-ID'] = tenantId;
|
|
||||||
|
|
||||||
const url = `/api/v1${ep.path}`;
|
|
||||||
const t0 = performance.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
let fetchOptions: RequestInit = { method: ep.method, headers };
|
|
||||||
// For non-GET we don't send a body to avoid validation errors
|
|
||||||
const res = await fetch(url, fetchOptions);
|
|
||||||
const ms = Math.round(performance.now() - t0);
|
|
||||||
let preview: string | null = null;
|
|
||||||
try {
|
|
||||||
const text = await res.text();
|
|
||||||
const obj = JSON.parse(text);
|
|
||||||
preview = JSON.stringify(obj, null, 2).slice(0, 500);
|
|
||||||
if (JSON.stringify(obj, null, 2).length > 500) preview += '\n...';
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
results[ep.id] = { status: res.status, ok: res.ok, ms, error: null, preview, tested: true, loading: false };
|
|
||||||
} catch (e: any) {
|
|
||||||
const ms = Math.round(performance.now() - t0);
|
|
||||||
results[ep.id] = { status: null, ok: false, ms, error: e.message ?? 'Network error', preview: null, tested: true, loading: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
results = results;
|
|
||||||
recalcCounters();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testAll() {
|
|
||||||
isTesting = true;
|
|
||||||
totalOk = 0;
|
|
||||||
totalFail = 0;
|
|
||||||
for (const group of GROUPS) {
|
|
||||||
for (const ep of group.endpoints) {
|
|
||||||
testingId = ep.id;
|
|
||||||
await testEndpoint(ep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
testingId = null;
|
|
||||||
isTesting = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function recalcCounters() {
|
|
||||||
totalOk = Object.values(results).filter(r => r.tested && r.ok).length;
|
|
||||||
totalFail = Object.values(results).filter(r => r.tested && !r.ok).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusBadge(r: EndpointResult): { text: string; cls: string } {
|
|
||||||
if (r.loading) return { text: 'Probando...', cls: 'bg-gray-100 text-gray-600 animate-pulse' };
|
|
||||||
if (!r.tested) return { text: 'Sin probar', cls: 'bg-gray-100 text-gray-400' };
|
|
||||||
if (r.ok) return { text: `${r.status} OK`, cls: 'bg-green-100 text-green-700' };
|
|
||||||
return { text: r.status ? `${r.status} Error` : 'Fallo red', cls: 'bg-red-100 text-red-700' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function methodBadge(method: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
GET: 'bg-blue-100 text-blue-700',
|
|
||||||
POST: 'bg-green-100 text-green-700',
|
|
||||||
PUT: 'bg-yellow-100 text-yellow-700',
|
|
||||||
DELETE: 'bg-red-100 text-red-700',
|
|
||||||
PATCH: 'bg-purple-100 text-purple-700',
|
|
||||||
};
|
|
||||||
return map[method] ?? 'bg-gray-100 text-gray-700';
|
|
||||||
}
|
|
||||||
|
|
||||||
let expandedIds = new Set<string>();
|
|
||||||
function toggleExpand(id: string) {
|
|
||||||
if (expandedIds.has(id)) expandedIds.delete(id);
|
|
||||||
else expandedIds.add(id);
|
|
||||||
expandedIds = new Set(expandedIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
const testedCount = () => Object.values(results).filter(r => r.tested).length;
|
|
||||||
const totalEndpoints = GROUPS.reduce((acc, g) => acc + g.endpoints.length, 0);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Reporte de Endpoints - ServiceManager</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="md:flex md:items-center md:justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Reporte de Endpoints & Páginas</h2>
|
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
|
||||||
Diagnóstico de conectividad de todos los endpoints del backend y páginas del frontend.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex gap-2 md:mt-0">
|
|
||||||
<button
|
|
||||||
on:click={testAll}
|
|
||||||
disabled={isTesting}
|
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
||||||
>
|
|
||||||
{#if isTesting}
|
|
||||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
Probando endpoints...
|
|
||||||
{:else}
|
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
Probar Todo
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Summary Bar -->
|
|
||||||
{#if testedCount() > 0}
|
|
||||||
<div class="mb-6 grid grid-cols-3 gap-4">
|
|
||||||
<div class="bg-white rounded-lg border p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-gray-800">{testedCount()}/{totalEndpoints}</div>
|
|
||||||
<div class="text-xs text-gray-500 mt-1">Endpoints probados</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-green-50 rounded-lg border border-green-200 p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-green-700">{totalOk}</div>
|
|
||||||
<div class="text-xs text-green-600 mt-1">OK / Exitosos</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-red-50 rounded-lg border border-red-200 p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-red-700">{totalFail}</div>
|
|
||||||
<div class="text-xs text-red-600 mt-1">Errores / Fallidos</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="flex border-b border-gray-200 mb-6">
|
|
||||||
<button
|
|
||||||
on:click={() => activeTab = 'endpoints'}
|
|
||||||
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors {activeTab === 'endpoints' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
|
|
||||||
>
|
|
||||||
Endpoints Backend ({totalEndpoints})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
on:click={() => activeTab = 'pages'}
|
|
||||||
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors {activeTab === 'pages' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
|
|
||||||
>
|
|
||||||
Páginas Frontend ({FRONTEND_PAGES.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── Endpoints Tab ─────────────────────────────────────────────────────── -->
|
|
||||||
{#if activeTab === 'endpoints'}
|
|
||||||
<div class="space-y-6">
|
|
||||||
{#each GROUPS as group}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
|
||||||
<!-- Group header -->
|
|
||||||
<div class="flex items-center justify-between px-5 py-3 bg-gray-50 border-b border-gray-200">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full {group.color}">
|
|
||||||
{group.name}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-gray-400">{group.endpoints.length} endpoint{group.endpoints.length !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1 items-center">
|
|
||||||
{#each group.endpoints as ep}
|
|
||||||
{#if results[ep.id].tested}
|
|
||||||
<span class="w-2 h-2 rounded-full {results[ep.id].ok ? 'bg-green-400' : 'bg-red-400'}" title={ep.label}></span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Endpoints list -->
|
|
||||||
<div class="divide-y divide-gray-100">
|
|
||||||
{#each group.endpoints as ep}
|
|
||||||
{@const r = results[ep.id]}
|
|
||||||
{@const badge = statusBadge(r)}
|
|
||||||
<div class="px-5 py-3">
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
|
||||||
<!-- Method badge -->
|
|
||||||
<span class="text-xs font-bold px-2 py-0.5 rounded font-mono {methodBadge(ep.method)}">
|
|
||||||
{ep.method}
|
|
||||||
</span>
|
|
||||||
<!-- Path -->
|
|
||||||
<code class="text-xs text-gray-700 bg-gray-50 px-2 py-0.5 rounded border border-gray-200 font-mono flex-shrink-0">
|
|
||||||
/api/v1{ep.path}
|
|
||||||
</code>
|
|
||||||
<!-- Label -->
|
|
||||||
<span class="text-sm text-gray-700 flex-1 min-w-0 truncate">{ep.label}</span>
|
|
||||||
|
|
||||||
<!-- Status + timing -->
|
|
||||||
<div class="flex items-center gap-2 ml-auto flex-shrink-0">
|
|
||||||
{#if r.ms !== null && r.tested}
|
|
||||||
<span class="text-xs text-gray-400">{r.ms}ms</span>
|
|
||||||
{/if}
|
|
||||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full {badge.cls}">{badge.text}</span>
|
|
||||||
<!-- Test individual -->
|
|
||||||
<button
|
|
||||||
on:click={() => testEndpoint(ep)}
|
|
||||||
disabled={r.loading || isTesting}
|
|
||||||
class="ml-1 text-xs px-2 py-1 rounded border border-gray-200 hover:border-blue-300 hover:text-blue-600 text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{r.loading ? '...' : 'Probar'}
|
|
||||||
</button>
|
|
||||||
<!-- Expand preview -->
|
|
||||||
{#if r.tested && r.preview}
|
|
||||||
<button
|
|
||||||
on:click={() => toggleExpand(ep.id)}
|
|
||||||
class="text-xs px-2 py-1 rounded border border-gray-200 hover:border-blue-300 hover:text-blue-600 text-gray-500 transition-colors"
|
|
||||||
>
|
|
||||||
{expandedIds.has(ep.id) ? 'Ocultar' : 'Ver respuesta'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<p class="mt-0.5 text-xs text-gray-400 ml-0.5">{ep.description}</p>
|
|
||||||
|
|
||||||
<!-- Error message -->
|
|
||||||
{#if r.tested && r.error}
|
|
||||||
<div class="mt-2 text-xs text-red-600 bg-red-50 rounded px-2 py-1.5 font-mono">{r.error}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Response preview -->
|
|
||||||
{#if expandedIds.has(ep.id) && r.preview}
|
|
||||||
<pre class="mt-2 text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded p-3 overflow-x-auto whitespace-pre-wrap break-words max-h-48">{r.preview}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- ─── Frontend Pages Tab ─────────────────────────────────────────────────── -->
|
|
||||||
{#if activeTab === 'pages'}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Página</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ruta</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Descripción</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roles</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Acción</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-100">
|
|
||||||
{#each FRONTEND_PAGES as pg}
|
|
||||||
<tr class="hover:bg-gray-50 transition-colors">
|
|
||||||
<td class="px-6 py-3">
|
|
||||||
<span class="text-sm font-medium text-gray-900">{pg.label}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3">
|
|
||||||
<code class="text-xs bg-gray-100 text-gray-700 px-2 py-0.5 rounded font-mono">{pg.href}</code>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3">
|
|
||||||
<span class="text-xs text-gray-500">{pg.description}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3">
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{#each pg.roles as role}
|
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">{role}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3">
|
|
||||||
<a
|
|
||||||
href={pg.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-xs text-blue-600 hover:text-blue-800 underline font-medium"
|
|
||||||
>
|
|
||||||
Abrir ↗
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Notes -->
|
|
||||||
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
<strong>Nota:</strong> Las páginas se abren en una nueva pestaña para verificar su renderizado.
|
|
||||||
Asegúrate de estar autenticado antes de acceder a rutas protegidas.
|
|
||||||
</p>
|
|
||||||
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
|
||||||
{#each FRONTEND_PAGES as pg}
|
|
||||||
<a
|
|
||||||
href={pg.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 text-xs text-gray-700 hover:text-blue-700 transition-all group"
|
|
||||||
>
|
|
||||||
<svg class="w-3.5 h-3.5 text-gray-400 group-hover:text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
<span class="truncate font-medium">{pg.label}</span>
|
|
||||||
<code class="ml-auto text-gray-400 text-xs flex-shrink-0">{pg.href}</code>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|||||||
BIN
frontend-internal/static/favicon.png
Normal file
BIN
frontend-internal/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -1,39 +1,35 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
server: {
|
server: {
|
||||||
// Puerto: dentro del contenedor siempre 3000; Docker mapea 3001:3000 al host
|
port: parseInt(process.env.PORT || '3001'),
|
||||||
port: parseInt(process.env.PORT || '3001'),
|
host: '0.0.0.0',
|
||||||
host: '0.0.0.0',
|
watch: {
|
||||||
watch: {
|
usePolling: true,
|
||||||
usePolling: true,
|
interval: 500
|
||||||
interval: 500
|
},
|
||||||
},
|
hmr: {
|
||||||
// HMR: el browser llega al contenedor a través del puerto 3001 del host
|
host: 'localhost',
|
||||||
hmr: {
|
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
|
||||||
host: 'localhost',
|
},
|
||||||
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
|
fs: {
|
||||||
},
|
allow: ['/app', '.'],
|
||||||
// Permitir que Vite sirva archivos del filesystem del contenedor
|
strict: false
|
||||||
fs: {
|
},
|
||||||
allow: ['/app', '.'],
|
proxy: {
|
||||||
strict: false
|
'/api': {
|
||||||
},
|
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
|
||||||
proxy: {
|
changeOrigin: true,
|
||||||
'/api': {
|
rewrite: (path) => path.replace(/^\/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'
|
||||||
preview: {
|
},
|
||||||
port: parseInt(process.env.PORT || '3001'),
|
build: {
|
||||||
host: '0.0.0.0'
|
target: 'esnext'
|
||||||
},
|
}
|
||||||
build: {
|
|
||||||
target: 'esnext'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
BIN
migrations.sql
Normal file
BIN
migrations.sql
Normal file
Binary file not shown.
43
scripts/README-powershell.md
Normal file
43
scripts/README-powershell.md
Normal 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.
|
||||||
29
scripts/README-uso-rapido.md
Normal file
29
scripts/README-uso-rapido.md
Normal 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.
|
||||||
@@ -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])
|
|
||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
@@ -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
22
scripts/reset-fabrica.sh
Normal 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
11
show_context.py
Normal 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()}")
|
||||||
@@ -7,11 +7,42 @@ Async database session management para Celery workers
|
|||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
from sqlalchemy import DateTime, func
|
from sqlalchemy import DateTime, func
|
||||||
|
from sqlalchemy import types as sa_types
|
||||||
|
from sqlalchemy.types import TypeDecorator, CHAR
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class GUID(TypeDecorator):
|
||||||
|
"""UUID portable: UUID nativo en Postgres, CHAR(36) en otros dialectos (SQLite para tests)."""
|
||||||
|
|
||||||
|
impl = CHAR
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def load_dialect_impl(self, dialect):
|
||||||
|
if dialect.name == "postgresql":
|
||||||
|
return dialect.type_descriptor(PG_UUID(as_uuid=True))
|
||||||
|
return dialect.type_descriptor(CHAR(36))
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if dialect.name == "postgresql":
|
||||||
|
return value
|
||||||
|
if isinstance(value, uuid.UUID):
|
||||||
|
return str(value)
|
||||||
|
return str(uuid.UUID(str(value)))
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, uuid.UUID):
|
||||||
|
return uuid.UUID(str(value))
|
||||||
|
return value
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
Reference in New Issue
Block a user