Compare commits

..

1 Commits

Author SHA1 Message Date
96cd09476c Auditoria funcionando 2026-02-12 12:23:11 -07:00
27 changed files with 412 additions and 237 deletions

View File

@@ -1,7 +1,7 @@
""" """
Audit Schemas - ServiceManagerWeb Audit Schemas - ServiceManagerWeb
Schemas Pydantic para endpoints de auditor├¡a Schemas Pydantic para endpoints de auditoría
""" """
from pydantic import BaseModel, Field, UUID4 from pydantic import BaseModel, Field, UUID4
@@ -11,7 +11,7 @@ from datetime import datetime
class AuditLogBase(BaseModel): class AuditLogBase(BaseModel):
"""Schema base para audit logs.""" """Schema base para audit logs."""
action: str = Field(..., description="Acci├│n realizada (ej: ticket.create)") action: str = Field(..., description="Acción realizada (ej: ticket.create)")
resource_type: str = Field(..., description="Tipo de recurso (ticket, user, etc.)") resource_type: str = Field(..., description="Tipo de recurso (ticket, user, etc.)")
resource_id: Optional[UUID4] = Field(None, description="ID del recurso afectado") resource_id: Optional[UUID4] = Field(None, description="ID del recurso afectado")
extra_metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional", alias="metadata") extra_metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional", alias="metadata")
@@ -21,18 +21,18 @@ class AuditLogResponse(AuditLogBase):
""" """
Schema de respuesta para audit logs. Schema de respuesta para audit logs.
Incluye toda la informaci├│n del log con datos del usuario. Incluye toda la información del log con datos del usuario.
""" """
id: UUID4 id: UUID4
tenant_id: UUID4 tenant_id: UUID4
user_id: Optional[UUID4] user_id: Optional[UUID4]
# Informaci├│n del usuario (si existe) # Información del usuario (si existe)
user_email: Optional[str] = None user_email: Optional[str] = None
user_name: Optional[str] = None user_name: Optional[str] = None
user_role: Optional[str] = None user_role: Optional[str] = None
# Contexto de la acci├│n # Contexto de la acción
ip_address: Optional[str] ip_address: Optional[str]
user_agent: Optional[str] user_agent: Optional[str]
correlation_id: Optional[UUID4] correlation_id: Optional[UUID4]
@@ -45,7 +45,7 @@ class AuditLogResponse(AuditLogBase):
created_at: datetime created_at: datetime
# Display friendly # Display friendly
action_display: str = Field(description="Acci├│n en formato amigable") action_display: str = Field(description="Acción en formato amigable")
class Config: class Config:
from_attributes = True from_attributes = True
@@ -55,15 +55,15 @@ class AuditLogFilters(BaseModel):
""" """
Filtros para consulta de audit logs. Filtros para consulta de audit logs.
Permite filtrar por m├║ltiples criterios. Permite filtrar por múltiples criterios.
""" """
# Paginaci├│n # Paginación
page: int = Field(default=1, ge=1, description="N├║mero de p├ígina") page: int = Field(default=1, ge=1, description="Número de página")
per_page: int = Field(default=50, ge=1, le=100, description="Elementos por p├ígina") per_page: int = Field(default=50, ge=1, le=100, description="Elementos por página")
# Filtros # Filtros
user_id: Optional[UUID4] = Field(None, description="Filtrar por usuario") user_id: Optional[UUID4] = Field(None, description="Filtrar por usuario")
action: Optional[str] = Field(None, description="Filtrar por acci├│n espec├¡fica") action: Optional[str] = Field(None, description="Filtrar por acción específica")
resource_type: Optional[str] = Field(None, description="Filtrar por tipo de recurso") resource_type: Optional[str] = Field(None, description="Filtrar por tipo de recurso")
resource_id: Optional[UUID4] = Field(None, description="Filtrar por ID de recurso") resource_id: Optional[UUID4] = Field(None, description="Filtrar por ID de recurso")
@@ -71,25 +71,25 @@ class AuditLogFilters(BaseModel):
date_from: Optional[datetime] = Field(None, description="Fecha inicio (ISO 8601)") date_from: Optional[datetime] = Field(None, description="Fecha inicio (ISO 8601)")
date_to: Optional[datetime] = Field(None, description="Fecha fin (ISO 8601)") date_to: Optional[datetime] = Field(None, description="Fecha fin (ISO 8601)")
# B├║squeda # Búsqueda
search: Optional[str] = Field(None, description="B├║squeda en acciones o recursos") search: Optional[str] = Field(None, description="Búsqueda en acciones o recursos")
class AuditLogStats(BaseModel): class AuditLogStats(BaseModel):
""" """
Estad├¡sticas de auditor├¡a. Estadísticas de auditoría.
Resumen de actividad del sistema. Resumen de actividad del sistema.
""" """
total_actions: int = Field(description="Total de acciones registradas") total_actions: int = Field(description="Total de acciones registradas")
actions_today: int = Field(description="Acciones en las ├║ltimas 24 horas") actions_today: int = Field(description="Acciones en las últimas 24 horas")
actions_this_week: int = Field(description="Acciones en los ├║ltimos 7 d├¡as") actions_this_week: int = Field(description="Acciones en los últimos 7 días")
# Top acciones # Top acciones
top_actions: Dict[str, int] = Field(description="Acciones m├ís frecuentes") top_actions: Dict[str, int] = Field(description="Acciones más frecuentes")
# Top usuarios # Top usuarios
top_users: Dict[str, int] = Field(description="Usuarios m├ís activos") top_users: Dict[str, int] = Field(description="Usuarios más activos")
# Actividad por tipo de recurso # Actividad por tipo de recurso
by_resource_type: Dict[str, int] = Field(description="Acciones por tipo de recurso") by_resource_type: Dict[str, int] = Field(description="Acciones por tipo de recurso")
@@ -101,9 +101,9 @@ class AuditLogListResponse(BaseModel):
""" """
logs: list[AuditLogResponse] logs: list[AuditLogResponse]
total: int = Field(description="Total de registros") total: int = Field(description="Total de registros")
page: int = Field(description="P├ígina actual") page: int = Field(description="Página actual")
per_page: int = Field(description="Registros por p├ígina") per_page: int = Field(description="Registros por página")
total_pages: int = Field(description="Total de p├íginas") total_pages: int = Field(description="Total de páginas")
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -1,7 +1,7 @@
""" """
Audit Endpoints - ServiceManagerWeb Audit Endpoints - ServiceManagerWeb
Endpoints para consulta de logs de auditor├¡a. Endpoints para consulta de logs de auditoría.
Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR
""" """
@@ -34,14 +34,14 @@ def require_auditor_role(current_user: User = Depends(get_current_user)) -> User
""" """
Dependency que verifica que el usuario tenga rol de auditor. Dependency que verifica que el usuario tenga rol de auditor.
Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden ver logs de auditor├¡a. Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden ver logs de auditoría.
""" """
allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR] allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
if current_user.role not in allowed_roles: if current_user.role not in allowed_roles:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditor├¡a" detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría"
) )
return current_user return current_user
@@ -49,18 +49,18 @@ def require_auditor_role(current_user: User = Depends(get_current_user)) -> User
@router.get("/", response_model=AuditLogListResponse) @router.get("/", response_model=AuditLogListResponse)
async def get_audit_logs( async def get_audit_logs(
# Paginaci├│n # Paginación
page: int = Query(default=1, ge=1, description="N├║mero de p├ígina"), page: int = Query(default=1, ge=1, description="Número de página"),
per_page: int = Query(default=50, ge=1, le=100, description="Registros por p├ígina"), per_page: int = Query(default=50, ge=1, le=100, description="Registros por página"),
# Filtros # Filtros
user_id: Optional[uuid.UUID] = Query(None, description="Filtrar por usuario"), user_id: Optional[uuid.UUID] = Query(None, description="Filtrar por usuario"),
action: Optional[str] = Query(None, description="Filtrar por acci├│n"), action: Optional[str] = Query(None, description="Filtrar por acción"),
resource_type: Optional[str] = Query(None, description="Filtrar por tipo de recurso"), resource_type: Optional[str] = Query(None, description="Filtrar por tipo de recurso"),
resource_id: Optional[uuid.UUID] = Query(None, description="Filtrar por ID de recurso"), resource_id: Optional[uuid.UUID] = Query(None, description="Filtrar por ID de recurso"),
date_from: Optional[datetime] = Query(None, description="Fecha desde"), date_from: Optional[datetime] = Query(None, description="Fecha desde"),
date_to: Optional[datetime] = Query(None, description="Fecha hasta"), date_to: Optional[datetime] = Query(None, description="Fecha hasta"),
search: Optional[str] = Query(None, description="B├║squeda en acci├│n o email"), search: Optional[str] = Query(None, description="Búsqueda en acción o email"),
# Dependencies # Dependencies
current_user: User = Depends(require_auditor_role), current_user: User = Depends(require_auditor_role),
@@ -68,17 +68,17 @@ async def get_audit_logs(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
Obtener logs de auditor├¡a con filtros y paginaci├│n. Obtener logs de auditoría con filtros y paginación.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
**Filtros disponibles**: **Filtros disponibles**:
- `user_id`: Acciones de un usuario espec├¡fico - `user_id`: Acciones de un usuario específico
- `action`: Tipo de acci├│n (ej: "ticket.create") - `action`: Tipo de acción (ej: "ticket.create")
- `resource_type`: Tipo de recurso (ej: "ticket") - `resource_type`: Tipo de recurso (ej: "ticket")
- `resource_id`: ID de recurso espec├¡fico - `resource_id`: ID de recurso específico
- `date_from`, `date_to`: Rango de fechas - `date_from`, `date_to`: Rango de fechas
- `search`: B├║squeda en acciones - `search`: Búsqueda en acciones
**Retorna**: Lista paginada de audit logs **Retorna**: Lista paginada de audit logs
""" """
@@ -95,7 +95,7 @@ async def get_audit_logs(
) )
# Query base - solo logs del tenant actual # Query base - solo logs del tenant actual
# Usar selectinload para cargar la relaci├│n user (eager loading para async) # Usar selectinload para cargar la relación user (eager loading para async)
query = ( query = (
select(AuditLog) select(AuditLog)
.where(AuditLog.tenant_id == current_tenant.id) .where(AuditLog.tenant_id == current_tenant.id)
@@ -119,16 +119,16 @@ async def get_audit_logs(
query = query.where(AuditLog.created_at >= date_from) query = query.where(AuditLog.created_at >= date_from)
if date_to: if date_to:
# Agregar 1 d├¡a para incluir todo el d├¡a # Agregar 1 día para incluir todo el día
date_to_end = date_to + timedelta(days=1) date_to_end = date_to + timedelta(days=1)
query = query.where(AuditLog.created_at < date_to_end) query = query.where(AuditLog.created_at < date_to_end)
if search: if search:
# B├║squeda en action # Búsqueda en action
search_filter = AuditLog.action.ilike(f"%{search}%") search_filter = AuditLog.action.ilike(f"%{search}%")
query = query.where(search_filter) query = query.where(search_filter)
# Ordenar por fecha descendente (m├ís recientes primero) # Ordenar por fecha descendente (más recientes primero)
query = query.order_by(desc(AuditLog.created_at)) query = query.order_by(desc(AuditLog.created_at))
# Contar total antes de paginar # Contar total antes de paginar
@@ -136,7 +136,7 @@ async def get_audit_logs(
total_result = await db.execute(count_query) total_result = await db.execute(count_query)
total = total_result.scalar() or 0 total = total_result.scalar() or 0
# Aplicar paginaci├│n # Aplicar paginación
offset = (page - 1) * per_page offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page) query = query.offset(offset).limit(per_page)
@@ -144,7 +144,7 @@ async def get_audit_logs(
result = await db.execute(query) result = await db.execute(query)
logs = result.scalars().all() logs = result.scalars().all()
# Calcular total de p├íginas # Calcular total de páginas
total_pages = (total + per_page - 1) // per_page total_pages = (total + per_page - 1) // per_page
# Convertir a response schema (agregar info del usuario) # Convertir a response schema (agregar info del usuario)
@@ -193,11 +193,11 @@ async def get_audit_stats(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
Obtener estad├¡sticas de auditor├¡a del tenant. Obtener estadísticas de auditoría del tenant.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
**Retorna**: Estad├¡sticas de actividad **Retorna**: Estadísticas de actividad
""" """
logger.info( logger.info(
"Fetching audit stats", "Fetching audit stats",
@@ -214,7 +214,7 @@ async def get_audit_stats(
total_result = await db.execute(total_query) total_result = await db.execute(total_query)
total_actions = total_result.scalar() or 0 total_actions = total_result.scalar() or 0
# Acciones hoy (├║ltimas 24 horas) # Acciones hoy (últimas 24 horas)
today_start = now - timedelta(days=1) today_start = now - timedelta(days=1)
today_query = select(func.count()).select_from(AuditLog).where( today_query = select(func.count()).select_from(AuditLog).where(
and_( and_(
@@ -225,7 +225,7 @@ async def get_audit_stats(
today_result = await db.execute(today_query) today_result = await db.execute(today_query)
actions_today = today_result.scalar() or 0 actions_today = today_result.scalar() or 0
# Acciones esta semana (├║ltimos 7 d├¡as) # Acciones esta semana (últimos 7 días)
week_start = now - timedelta(days=7) week_start = now - timedelta(days=7)
week_query = select(func.count()).select_from(AuditLog).where( week_query = select(func.count()).select_from(AuditLog).where(
and_( and_(
@@ -236,7 +236,7 @@ async def get_audit_stats(
week_result = await db.execute(week_query) week_result = await db.execute(week_query)
actions_this_week = week_result.scalar() or 0 actions_this_week = week_result.scalar() or 0
# Top 5 acciones m├ís frecuentes # Top 5 acciones más frecuentes
top_actions_query = select( top_actions_query = select(
AuditLog.action, AuditLog.action,
func.count(AuditLog.id).label('count') func.count(AuditLog.id).label('count')
@@ -267,7 +267,7 @@ async def get_audit_stats(
by_resource_type = {row.resource_type: row.count for row in by_resource_result} by_resource_type = {row.resource_type: row.count for row in by_resource_result}
# Top usuarios (necesitamos hacer join - simplificado por ahora) # Top usuarios (necesitamos hacer join - simplificado por ahora)
# En producci├│n podr├¡as hacer un join con users para obtener nombres # En producción podrías hacer un join con users para obtener nombres
top_users = {} # Placeholder - implementar con join si es necesario top_users = {} # Placeholder - implementar con join si es necesario
return AuditLogStats( return AuditLogStats(
@@ -288,7 +288,7 @@ async def get_audit_log_detail(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
Obtener detalle de un audit log espec├¡fico. Obtener detalle de un audit log específico.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR

View File

@@ -4,13 +4,14 @@ Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización Endpoints para autenticación y autorización
""" """
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import Optional from typing import Optional
from datetime import datetime # Para actualizar last_login
import structlog import structlog
from app.core.database import get_db from app.core.database import get_db
@@ -18,6 +19,8 @@ from app.core.security import security
from app.core.config import get_settings from app.core.config import get_settings
from app.models.user import User from app.models.user import User
from app.models.tenant import Tenant from app.models.tenant import Tenant
from app.services.audit_service import AuditService # Servicio de auditoría
from app.services.token_service import TokenService # Servicio de tokens
router = APIRouter() router = APIRouter()
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -67,13 +70,17 @@ class TokenResponse(BaseModel):
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
async def login( async def login(
login_data: LoginRequest, login_data: LoginRequest,
request: Request, # Agregar Request para capturar IP y user agent
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
Authenticate user and return access/refresh tokens. Authenticate user and return access/refresh tokens.
**Auditoría**: Registra login exitoso y fallido en audit_logs
Args: Args:
login_data: Login credentials login_data: Login credentials
request: FastAPI Request (para auditoría)
db: Database session db: Database session
Returns: Returns:
@@ -99,6 +106,24 @@ async def login(
"Login failed - invalid credentials", "Login failed - invalid credentials",
email=login_data.email email=login_data.email
) )
# 🔍 AUDITORÍA: Registrar intento fallido
if user: # Solo si el usuario existe (password incorrecto)
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None, # NULL porque aún no autenticado
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"email": login_data.email,
"reason": "invalid_password"
},
request=request
)
await db.commit() # Commit del audit log
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas" detail="Credenciales inválidas"
@@ -110,11 +135,31 @@ async def login(
"Login failed - user inactive", "Login failed - user inactive",
email=login_data.email email=login_data.email
) )
# 🔍 AUDITORÍA: Registrar intento con usuario inactivo
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None,
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"email": login_data.email,
"reason": "user_inactive"
},
request=request
)
await db.commit()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario inactivo" detail="Usuario inactivo"
) )
# Actualizar último login
user.last_login = datetime.utcnow()
# Create tokens # Create tokens
token_data = { token_data = {
"sub": str(user.id), "sub": str(user.id),
@@ -126,6 +171,62 @@ async def login(
access_token = security.create_access_token(token_data) access_token = security.create_access_token(token_data)
refresh_token = security.create_refresh_token(token_data) refresh_token = security.create_refresh_token(token_data)
# Extraer información del dispositivo para rastreo
user_agent = request.headers.get("user-agent")
client_ip = request.client.host if request.client else None
# device_id: El frontend puede enviarlo en el futuro como header
device_id = request.headers.get("x-device-id") # Opcional
# device_name: Simplificado del user-agent (ej: "Chrome en Windows")
device_name = None
if user_agent:
# Parseo básico para obtener un nombre legible
if "Chrome" in user_agent:
device_name = "Chrome"
elif "Firefox" in user_agent:
device_name = "Firefox"
elif "Safari" in user_agent:
device_name = "Safari"
elif "Edge" in user_agent:
device_name = "Edge"
else:
device_name = "Unknown Browser"
# Agregar OS
if "Windows" in user_agent:
device_name += " en Windows"
elif "Macintosh" in user_agent or "Mac OS" in user_agent:
device_name += " en macOS"
elif "Linux" in user_agent:
device_name += " en Linux"
elif "Android" in user_agent:
device_name += " en Android"
elif "iPhone" in user_agent or "iPad" in user_agent:
device_name += " en iOS"
# Persistir refresh token en BD con tracking completo
await TokenService.create_refresh_token(
db=db,
user=user,
refresh_token=refresh_token,
device_id=device_id,
device_name=device_name,
user_agent=user_agent,
ip_address=client_ip
)
# 🔍 AUDITORÍA: Registrar login exitoso
await AuditService.log_login(
db=db,
user=user,
request=request,
success=True
)
# Commit de todos los cambios (last_login + refresh token + audit log)
await db.commit()
logger.info( logger.info(
"Login successful", "Login successful",
email=login_data.email, email=login_data.email,
@@ -159,6 +260,9 @@ async def refresh_token(
""" """
Refresh access token using refresh token. Refresh access token using refresh token.
Verifica el JWT, valida en BD que no esté revocado/expirado,
actualiza estadísticas de uso y genera nuevo access token.
Args: Args:
refresh_data: Refresh token data refresh_data: Refresh token data
db: Database session db: Database session
@@ -171,18 +275,26 @@ async def refresh_token(
""" """
logger.info("Token refresh attempt") logger.info("Token refresh attempt")
# Verify refresh token # 1. Verify refresh token JWT signature
payload = security.verify_token(refresh_data.refresh_token) payload = security.verify_token(refresh_data.refresh_token)
if not payload or payload.get("type") != "refresh": if not payload or payload.get("type") != "refresh":
logger.warning("Token refresh failed - invalid token") logger.warning("Token refresh failed - invalid JWT")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token" detail="Invalid refresh token"
) )
# TODO: Check if refresh token exists in database and is not revoked # 2. Verificar que el token exista en BD y no esté revocado/expirado
# También actualiza last_used_at y usage_count automáticamente
db_token = await TokenService.verify_refresh_token(db, refresh_data.refresh_token)
if not db_token:
logger.warning("Token refresh failed - token not found, revoked or expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked refresh token"
)
# Create new access token # 3. Create new access token
token_data = { token_data = {
"sub": payload["sub"], "sub": payload["sub"],
"email": payload["email"], "email": payload["email"],
@@ -192,7 +304,15 @@ async def refresh_token(
access_token = security.create_access_token(token_data) access_token = security.create_access_token(token_data)
logger.info("Token refresh successful", user_id=payload["sub"]) # 4. Commit actualización de estadísticas de uso
await db.commit()
logger.info(
"Token refresh successful",
user_id=payload["sub"],
token_id=str(db_token.id),
usage_count=db_token.usage_count
)
return TokenResponse( return TokenResponse(
access_token=access_token, access_token=access_token,
@@ -206,14 +326,17 @@ async def logout(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
Logout user and revoke refresh token. Logout user and revoke all refresh tokens.
Revoca todos los tokens del usuario (logout en todos los dispositivos)
y registra quién ejecutó la revocación para auditoría.
Args: Args:
token: Access token token: Access token
db: Database session db: Database session
Returns: Returns:
Success message Success message with count of revoked tokens
""" """
logger.info("Logout attempt") logger.info("Logout attempt")
@@ -225,11 +348,31 @@ async def logout(
detail="Invalid token" detail="Invalid token"
) )
# TODO: Revoke refresh token in database # Extraer user_id del token
user_id_str = payload.get("sub")
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
logger.info("Logout successful", user_id=payload["sub"]) user_id = uuid.UUID(user_id_str)
return {"message": "Successfully logged out"} # Revocar todos los tokens del usuario con auditoría de quién lo revocó
count = await TokenService.revoke_all_user_tokens(
db=db,
user_id=user_id,
revoked_by_user_id=user_id # El usuario se revoca a sí mismo
)
await db.commit()
logger.info("Logout successful", user_id=str(user_id), tokens_revoked=count)
return {
"message": "Successfully logged out from all devices",
"tokens_revoked": count
}
@router.get("/me") @router.get("/me")

View File

@@ -240,7 +240,7 @@ async def get_tickets(
"status": t.status.value, "status": t.status.value,
"priority": t.priority.value, "priority": t.priority.value,
"category_id": str(t.category_id) if t.category_id else None, "category_id": str(t.category_id) if t.category_id else None,
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, "affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORREGIDO
"created_by": str(t.created_by), "created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None, "assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at, "created_at": t.created_at,

View File

@@ -23,6 +23,13 @@ api_router.include_router(
tags=["authentication"] tags=["authentication"]
) )
# Audit routes (antes de otros para logging)
api_router.include_router(
audit.router,
prefix="/audit",
tags=["audit"]
)
api_router.include_router( api_router.include_router(
tenants.router, tenants.router,
prefix="/tenants", prefix="/tenants",
@@ -60,10 +67,3 @@ api_router.include_router(
prefix="/client-profile", prefix="/client-profile",
tags=["client-profile"] tags=["client-profile"]
) )
# Audit routes
api_router.include_router(
audit.router,
prefix="/audit",
tags=["audit"]
)

View File

@@ -23,8 +23,8 @@ from app.models.user import User
from app.models.ticket import Ticket from app.models.ticket import Ticket
from app.models.comment import TicketComment from app.models.comment import TicketComment
from app.models.attachment import TicketAttachment from app.models.attachment import TicketAttachment
from app.models.audit import AuditLog from app.models.audit import AuditLog # Nuevo modelo para auditoría
from app.models.refresh_token import RefreshToken from app.models.refresh_token import RefreshToken # Modelo para refresh tokens
from app.core.logging import setup_logging from app.core.logging import setup_logging
from app.api.v1.router import api_router from app.api.v1.router import api_router

View File

@@ -1,7 +1,7 @@
""" """
Audit Log Model - ServiceManagerWeb Audit Log Model - ServiceManagerWeb
Modelo para bit├ícora de auditor├¡a y compliance. Modelo para bitácora de auditoría y compliance.
Registra todas las acciones importantes del sistema. Registra todas las acciones importantes del sistema.
""" """
@@ -21,15 +21,15 @@ if TYPE_CHECKING:
class AuditLog(Base): class AuditLog(Base):
""" """
Bit├ícora de auditor├¡a para tracking completo de acciones. Bitácora de auditoría para tracking completo de acciones.
Registra: Registra:
- Qui├®n hizo la acci├│n (user_id) - Quién hizo la acción (user_id)
- Qu├® hizo (action) - Qué hizo (action)
- Sobre qu├® recurso (resource_type + resource_id) - Sobre qué recurso (resource_type + resource_id)
- Cu├índo lo hizo (created_at) - Cuándo lo hizo (created_at)
- Desde d├│nde (ip_address, user_agent) - Desde dónde (ip_address, user_agent)
- Qu├® cambi├│ (old_values, new_values) - Qué cambió (old_values, new_values)
""" """
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
@@ -42,7 +42,7 @@ class AuditLog(Base):
index=True index=True
) )
# Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema) # Usuario que ejecutó la acción (NULL = acción del sistema)
user_id: Mapped[Optional[uuid.UUID]] = mapped_column( user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"), ForeignKey("users.id", ondelete="SET NULL"),
@@ -50,7 +50,7 @@ class AuditLog(Base):
index=True index=True
) )
# Acci├│n realizada (ej: "user.login", "ticket.create", "ticket.assign") # Acción realizada (ej: "user.login", "ticket.create", "ticket.assign")
action: Mapped[str] = mapped_column( action: Mapped[str] = mapped_column(
String(100), String(100),
nullable=False, nullable=False,
@@ -87,14 +87,14 @@ class AuditLog(Base):
nullable=True nullable=True
) )
# Valores despu├®s del cambio (JSON) # Valores después del cambio (JSON)
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSONB, JSONB,
nullable=True nullable=True
) )
# Metadata adicional (cualquier info relevante) # Metadata adicional (cualquier info relevante)
# Nota: 'metadata' est├í reservado en SQLAlchemy, usamos 'extra_metadata' # Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column( extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
'metadata', # Nombre real de la columna en BD 'metadata', # Nombre real de la columna en BD
JSONB, JSONB,
@@ -113,14 +113,14 @@ class AuditLog(Base):
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id]) tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id]) user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
# ├ìndices compuestos para queries comunes # Índices compuestos para queries comunes
__table_args__ = ( __table_args__ = (
Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'), Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'),
Index('idx_audit_logs_resource', 'resource_type', 'resource_id'), Index('idx_audit_logs_resource', 'resource_type', 'resource_id'),
Index('idx_audit_logs_user_created', 'user_id', 'created_at'), Index('idx_audit_logs_user_created', 'user_id', 'created_at'),
) )
# Configuraci├│n del mapper: excluir updated_at porque audit logs son inmutables # Configuración del mapper: excluir updated_at porque audit logs son inmutables
__mapper_args__ = { __mapper_args__ = {
"exclude_properties": ["updated_at"] "exclude_properties": ["updated_at"]
} }
@@ -130,19 +130,19 @@ class AuditLog(Base):
@property @property
def action_display(self) -> str: def action_display(self) -> str:
"""Formato amigable de la acci├│n.""" """Formato amigable de la acción."""
parts = self.action.split('.') parts = self.action.split('.')
if len(parts) == 2: if len(parts) == 2:
resource, verb = parts resource, verb = parts
verb_map = { verb_map = {
'create': 'cre├│', 'create': 'creó',
'update': 'actualiz├│', 'update': 'actualizó',
'delete': 'elimin├│', 'delete': 'eliminó',
'login': 'inici├│ sesi├│n', 'login': 'inició sesión',
'logout': 'cerr├│ sesi├│n', 'logout': 'cerró sesión',
'assign': 'asign├│', 'assign': 'asignó',
'close': 'cerr├│', 'close': 'cerró',
'reopen': 'reabri├│' 'reopen': 'reabrió'
} }
return f"{verb_map.get(verb, verb)} {resource}" return f"{verb_map.get(verb, verb)} {resource}"
return self.action return self.action

View File

@@ -1,7 +1,7 @@
""" """
Refresh Token Model - ServiceManagerWeb Refresh Token Model - ServiceManagerWeb
Modelo para persistencia de refresh tokens con revocaci├│n y tracking. Modelo para persistencia de refresh tokens con revocación y tracking.
""" """
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer
@@ -19,16 +19,16 @@ if TYPE_CHECKING:
class RefreshToken(Base): class RefreshToken(Base):
""" """
Refresh Token persistente para gesti├│n de sesiones. Refresh Token persistente para gestión de sesiones.
Almacena refresh tokens con informaci├│n de dispositivo y permite Almacena refresh tokens con información de dispositivo y permite
revocaci├│n para mejorar la seguridad. revocación para mejorar la seguridad.
Caracter├¡sticas: Características:
- Token hasheado (no se guarda en texto plano) - Token hasheado (no se guarda en texto plano)
- Device fingerprinting - Device fingerprinting
- Revocaci├│n individual con tracking - Revocación individual con tracking
- Auto-expiraci├│n - Auto-expiración
- Tracking de IP y uso - Tracking de IP y uso
""" """
@@ -72,14 +72,14 @@ class RefreshToken(Base):
nullable=True nullable=True
) )
# Expiraci├│n del token # Expiración del token
expires_at: Mapped[datetime] = mapped_column( expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=False, nullable=False,
index=True index=True
) )
# Estado de revocaci├│n # Estado de revocación
revoked: Mapped[bool] = mapped_column( revoked: Mapped[bool] = mapped_column(
Boolean, Boolean,
default=False, default=False,
@@ -125,11 +125,11 @@ class RefreshToken(Base):
server_default="NOW()" server_default="NOW()"
) )
# Relaci├│n con usuario # Relación con usuario
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], back_populates="refresh_tokens") user: Mapped["User"] = relationship("User", foreign_keys=[user_id], back_populates="refresh_tokens")
revoker: Mapped[Optional["User"]] = relationship("User", foreign_keys=[revoked_by]) revoker: Mapped[Optional["User"]] = relationship("User", foreign_keys=[revoked_by])
# ├ìndices compuestos # Índices compuestos
__table_args__ = ( __table_args__ = (
Index('idx_refresh_tokens_user_expires', 'user_id', 'expires_at'), Index('idx_refresh_tokens_user_expires', 'user_id', 'expires_at'),
) )
@@ -140,10 +140,10 @@ class RefreshToken(Base):
@property @property
def is_valid(self) -> bool: def is_valid(self) -> bool:
""" """
Verificar si el token es v├ílido. Verificar si el token es válido.
Un token es v├ílido si: Un token es válido si:
- No est├í revocado - No está revocado
- No ha expirado - No ha expirado
""" """
return not self.revoked and self.expires_at > datetime.utcnow() return not self.revoked and self.expires_at > datetime.utcnow()
@@ -158,7 +158,7 @@ class RefreshToken(Base):
Marcar el token como revocado. Marcar el token como revocado.
Args: Args:
revoked_by: ID del usuario que revoc├│ el token revoked_by: ID del usuario que revocó el token
""" """
self.revoked = True self.revoked = True
self.revoked_at = datetime.utcnow() self.revoked_at = datetime.utcnow()

View File

@@ -78,11 +78,12 @@ class User(Base):
back_populates="assigned_to_user", back_populates="assigned_to_user",
foreign_keys="Ticket.assigned_to" foreign_keys="Ticket.assigned_to"
) )
# Refresh tokens: especificar user_id como FK (hay 2 FKs: user_id y revoked_by)
refresh_tokens: Mapped[List["RefreshToken"]] = relationship( refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
"RefreshToken", "RefreshToken",
back_populates="user", back_populates="user",
foreign_keys="RefreshToken.user_id", cascade="all, delete-orphan",
cascade="all, delete-orphan" foreign_keys="[RefreshToken.user_id]"
) )
# Unique constraint por tenant # Unique constraint por tenant

View File

@@ -1,8 +1,8 @@
""" """
Audit Service - ServiceManagerWeb Audit Service - ServiceManagerWeb
Funciones helper para facilitar el registro de auditor├¡a. Funciones helper para facilitar el registro de auditoría.
Simplifica el proceso de logging en toda la aplicaci├│n. Simplifica el proceso de logging en toda la aplicación.
""" """
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
@@ -19,9 +19,9 @@ logger = structlog.get_logger(__name__)
class AuditService: class AuditService:
""" """
Servicio centralizado para registro de auditor├¡a. Servicio centralizado para registro de auditoría.
Uso b├ísico: Uso básico:
await AuditService.log( await AuditService.log(
db=db, db=db,
tenant_id=tenant.id, tenant_id=tenant.id,
@@ -47,25 +47,25 @@ class AuditService:
request: Optional[Request] = None request: Optional[Request] = None
) -> AuditLog: ) -> AuditLog:
""" """
Registra una acci├│n en la bit├ícora de auditor├¡a. Registra una acción en la bitácora de auditoría.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
tenant_id: ID del tenant tenant_id: ID del tenant
action: Acci├│n realizada (formato: "recurso.verbo") action: Acción realizada (formato: "recurso.verbo")
Ejemplos: "user.login", "ticket.create", "ticket.assign" Ejemplos: "user.login", "ticket.create", "ticket.assign"
resource_type: Tipo de recurso ("user", "ticket", "comment", etc.) resource_type: Tipo de recurso ("user", "ticket", "comment", etc.)
resource_id: ID del recurso afectado (opcional) resource_id: ID del recurso afectado (opcional)
user_id: ID del usuario que ejecut├│ la acci├│n (opcional = sistema) user_id: ID del usuario que ejecutó la acción (opcional = sistema)
old_values: Valores antes del cambio (opcional) old_values: Valores antes del cambio (opcional)
new_values: Valores despu├®s del cambio (opcional) new_values: Valores después del cambio (opcional)
metadata: Informaci├│n adicional (opcional) metadata: Información adicional (opcional)
request: Request de FastAPI para extraer IP y user agent (opcional) request: Request de FastAPI para extraer IP y user agent (opcional)
Returns: Returns:
AuditLog creado AuditLog creado
""" """
# Extraer informaci├│n del request si est├í disponible # Extraer información del request si está disponible
ip_address = None ip_address = None
user_agent = None user_agent = None
correlation_id = None correlation_id = None
@@ -81,7 +81,7 @@ class AuditService:
# Correlation ID (si existe en el request state) # Correlation ID (si existe en el request state)
correlation_id = getattr(request.state, "correlation_id", None) correlation_id = getattr(request.state, "correlation_id", None)
# Crear registro de auditor├¡a # Crear registro de auditoría
audit_log = AuditLog( audit_log = AuditLog(
tenant_id=tenant_id, tenant_id=tenant_id,
user_id=user_id, user_id=user_id,
@@ -97,7 +97,7 @@ class AuditService:
) )
db.add(audit_log) db.add(audit_log)
await db.flush() # No commit, se har├í con la transacci├│n principal await db.flush() # No commit, se hará con la transacción principal
# Log estructurado para debugging # Log estructurado para debugging
logger.info( logger.info(
@@ -122,8 +122,8 @@ class AuditService:
Registra un intento de login. Registra un intento de login.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
user: Usuario que intent├│ loguearse user: Usuario que intentó loguearse
request: Request de FastAPI request: Request de FastAPI
success: Si el login fue exitoso success: Si el login fue exitoso
@@ -154,8 +154,8 @@ class AuditService:
Registra un logout. Registra un logout.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
user: Usuario que cerr├│ sesi├│n user: Usuario que cerró sesión
request: Request de FastAPI request: Request de FastAPI
Returns: Returns:
@@ -182,12 +182,12 @@ class AuditService:
request: Optional[Request] = None request: Optional[Request] = None
) -> AuditLog: ) -> AuditLog:
""" """
Registra la creaci├│n de un recurso. Registra la creación de un recurso.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
tenant_id: ID del tenant tenant_id: ID del tenant
user_id: ID del usuario que cre├│ el recurso user_id: ID del usuario que creó el recurso
resource_type: Tipo de recurso ("ticket", "user", etc.) resource_type: Tipo de recurso ("ticket", "user", etc.)
resource_id: ID del recurso creado resource_id: ID del recurso creado
new_values: Valores del nuevo recurso new_values: Valores del nuevo recurso
@@ -219,12 +219,12 @@ class AuditService:
request: Optional[Request] = None request: Optional[Request] = None
) -> AuditLog: ) -> AuditLog:
""" """
Registra la actualizaci├│n de un recurso. Registra la actualización de un recurso.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
tenant_id: ID del tenant tenant_id: ID del tenant
user_id: ID del usuario que actualiz├│ user_id: ID del usuario que actualizó
resource_type: Tipo de recurso resource_type: Tipo de recurso
resource_id: ID del recurso resource_id: ID del recurso
old_values: Valores anteriores old_values: Valores anteriores
@@ -257,12 +257,12 @@ class AuditService:
request: Optional[Request] = None request: Optional[Request] = None
) -> AuditLog: ) -> AuditLog:
""" """
Registra la eliminaci├│n de un recurso. Registra la eliminación de un recurso.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
tenant_id: ID del tenant tenant_id: ID del tenant
user_id: ID del usuario que elimin├│ user_id: ID del usuario que eliminó
resource_type: Tipo de recurso resource_type: Tipo de recurso
resource_id: ID del recurso eliminado resource_id: ID del recurso eliminado
old_values: Valores del recurso antes de eliminar old_values: Valores del recurso antes de eliminar

View File

@@ -1,7 +1,7 @@
""" """
Token Service - ServiceManagerWeb Token Service - ServiceManagerWeb
Servicio para gesti├│n de refresh tokens persistentes. Servicio para gestión de refresh tokens persistentes.
""" """
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,9 +21,9 @@ settings = get_settings()
class TokenService: class TokenService:
""" """
Servicio para gesti├│n de refresh tokens. Servicio para gestión de refresh tokens.
Proporciona m├®todos para crear, validar, revocar y limpiar Proporciona métodos para crear, validar, revocar y limpiar
refresh tokens persistentes. refresh tokens persistentes.
NOTA: Los tokens se almacenan directamente en BD (no hash) NOTA: Los tokens se almacenan directamente en BD (no hash)
@@ -44,10 +44,10 @@ class TokenService:
Crear y persistir un refresh token. Crear y persistir un refresh token.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
user: Usuario propietario del token user: Usuario propietario del token
refresh_token: Token JWT generado (se almacena directamente) refresh_token: Token JWT generado (se almacena directamente)
device_id: ID ├║nico del dispositivo (UUID generado por cliente) device_id: ID único del dispositivo (UUID generado por cliente)
device_name: Nombre del dispositivo (ej: "Chrome en Windows") device_name: Nombre del dispositivo (ej: "Chrome en Windows")
user_agent: User agent completo del navegador user_agent: User agent completo del navegador
ip_address: IP del cliente ip_address: IP del cliente
@@ -55,7 +55,7 @@ class TokenService:
Returns: Returns:
RefreshToken creado RefreshToken creado
""" """
# Calcular expiraci├│n # Calcular expiración
expires_at = datetime.utcnow() + timedelta( expires_at = datetime.utcnow() + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS days=settings.REFRESH_TOKEN_EXPIRE_DAYS
) )
@@ -92,16 +92,16 @@ class TokenService:
refresh_token: str refresh_token: str
) -> Optional[RefreshToken]: ) -> Optional[RefreshToken]:
""" """
Verificar que el refresh token exista y sea v├ílido. Verificar que el refresh token exista y sea válido.
Busca el JWT directamente en la BD y verifica su estado. Busca el JWT directamente en la BD y verifica su estado.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
refresh_token: Token JWT a verificar refresh_token: Token JWT a verificar
Returns: Returns:
RefreshToken si es v├ílido, None si no existe o est├í revocado/expirado RefreshToken si es válido, None si no existe o está revocado/expirado
""" """
# Buscar token directamente en BD (sin hash) # Buscar token directamente en BD (sin hash)
query = select(RefreshToken).where( query = select(RefreshToken).where(
@@ -114,7 +114,7 @@ class TokenService:
logger.warning("Refresh token not found in database") logger.warning("Refresh token not found in database")
return None return None
# Verificar si es v├ílido (usa property is_valid del modelo) # Verificar si es válido (usa property is_valid del modelo)
if not db_token.is_valid: if not db_token.is_valid:
logger.warning( logger.warning(
"Invalid refresh token", "Invalid refresh token",
@@ -124,7 +124,7 @@ class TokenService:
) )
return None return None
# Actualizar estad├¡sticas de uso # Actualizar estadísticas de uso
db_token.track_usage() db_token.track_usage()
await db.flush() await db.flush()
@@ -142,15 +142,15 @@ class TokenService:
revoked_by_user_id: Optional[uuid.UUID] = None revoked_by_user_id: Optional[uuid.UUID] = None
) -> bool: ) -> bool:
""" """
Revocar un refresh token espec├¡fico. Revocar un refresh token específico.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
refresh_token: Token JWT a revocar refresh_token: Token JWT a revocar
revoked_by_user_id: ID del usuario que revoca (para auditor├¡a) revoked_by_user_id: ID del usuario que revoca (para auditoría)
Returns: Returns:
True si se revoc├│, False si no se encontr├│ True si se revocó, False si no se encontró
""" """
# Buscar token directamente (sin hash) # Buscar token directamente (sin hash)
query = select(RefreshToken).where( query = select(RefreshToken).where(
@@ -163,7 +163,7 @@ class TokenService:
logger.warning("Refresh token not found for revocation") logger.warning("Refresh token not found for revocation")
return False return False
# Revocar usando m├®todo del modelo # Revocar usando método del modelo
db_token.revoke(revoked_by=revoked_by_user_id) db_token.revoke(revoked_by=revoked_by_user_id)
await db.flush() await db.flush()
@@ -183,15 +183,15 @@ class TokenService:
""" """
Revocar todos los tokens activos de un usuario. Revocar todos los tokens activos de un usuario.
├Ütil para logout en todos los dispositivos. Útil para logout en todos los dispositivos.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
user_id: ID del usuario user_id: ID del usuario
revoked_by_user_id: ID del usuario que ejecuta la revocaci├│n (para auditor├¡a) revoked_by_user_id: ID del usuario que ejecuta la revocación (para auditoría)
Returns: Returns:
N├║mero de tokens revocados Número de tokens revocados
""" """
# Buscar todos los tokens activos del usuario # Buscar todos los tokens activos del usuario
query = select(RefreshToken).where( query = select(RefreshToken).where(
@@ -226,12 +226,12 @@ class TokenService:
Tarea de mantenimiento para limpiar tokens antiguos. Tarea de mantenimiento para limpiar tokens antiguos.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
Returns: Returns:
N├║mero de tokens eliminados Número de tokens eliminados
""" """
# Eliminar tokens expirados hace m├ís de 7 d├¡as # Eliminar tokens expirados hace más de 7 días
cutoff_date = datetime.utcnow() - timedelta(days=7) cutoff_date = datetime.utcnow() - timedelta(days=7)
query = delete(RefreshToken).where( query = delete(RefreshToken).where(
@@ -254,7 +254,7 @@ class TokenService:
Obtener todos los tokens activos de un usuario. Obtener todos los tokens activos de un usuario.
Args: Args:
db: Sesi├│n de base de datos db: Sesión de base de datos
user_id: ID del usuario user_id: ID del usuario
Returns: Returns:

View File

@@ -1,11 +1,11 @@
"""add_audit_logs_table """add_audit_logs_table
Revision ID: a1b2c3d4e5f6 Revision ID: a1b2c3d4e5f6
Revises: 13362e8c493a Revises: 13362e8c493a
Create Date: 2026-02-12 10:00:00.000000 Create Date: 2026-02-12 10:00:00.000000
Registra el modelo AuditLog en Alembic. Registra el modelo AuditLog en Alembic.
La tabla audit_logs ya existe en schema.sql, esta migraci├│n solo La tabla audit_logs ya existe en schema.sql, esta migración solo
la registra en el control de versiones de Alembic. la registra en el control de versiones de Alembic.
""" """
from alembic import op from alembic import op
@@ -23,8 +23,8 @@ def upgrade():
""" """
Verificar que audit_logs existe y registrarla en Alembic. Verificar que audit_logs existe y registrarla en Alembic.
La tabla fue creada por schema.sql, esta migraci├│n solo verifica La tabla fue creada por schema.sql, esta migración solo verifica
que exista y est├í disponible para usar. que exista y está disponible para usar.
""" """
from sqlalchemy import inspect from sqlalchemy import inspect
@@ -33,10 +33,10 @@ def upgrade():
tables = inspector.get_table_names() tables = inspector.get_table_names()
if 'audit_logs' in tables: if 'audit_logs' in tables:
print(" Tabla audit_logs encontrada (creada por schema.sql)") print(" Tabla audit_logs encontrada (creada por schema.sql)")
print(" Modelo AuditLog registrado en Alembic") print(" Modelo AuditLog registrado en Alembic")
# Verificar que tenga los ├¡ndices necesarios # Verificar que tenga los índices necesarios
existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')] existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')]
missing_indexes = [] missing_indexes = []
@@ -53,17 +53,17 @@ def upgrade():
missing_indexes.append(idx) missing_indexes.append(idx)
if missing_indexes: if missing_indexes:
print(f"ÔÜá´©Å ├ìndices faltantes: {', '.join(missing_indexes)}") print(f"⚠️ Índices faltantes: {', '.join(missing_indexes)}")
print(" (Esto es normal si usaste schema.sql completo)") print(" (Esto es normal si usaste schema.sql completo)")
else: else:
print("Ô£à Todos los ├¡ndices necesarios est├ín presentes") print(" Todos los índices necesarios están presentes")
else: else:
print("ÔÜá´©Å La tabla audit_logs NO existe") print("⚠️ La tabla audit_logs NO existe")
print(" Ejecuta: docker-compose exec -T postgres psql -U postgres -d servicemanager < db/schema.sql") print(" Ejecuta: docker-compose exec -T postgres psql -U postgres -d servicemanager < db/schema.sql")
print(" O crea la tabla manualmente desde schema.sql") print(" O crea la tabla manualmente desde schema.sql")
# No crear la tabla aqu├¡ - debe venir de schema.sql para mantener consistencia # No crear la tabla aquí - debe venir de schema.sql para mantener consistencia
raise Exception( raise Exception(
"La tabla audit_logs no existe. " "La tabla audit_logs no existe. "
"Por favor ejecuta el schema.sql completo primero." "Por favor ejecuta el schema.sql completo primero."
@@ -76,6 +76,6 @@ def downgrade():
Solo des-registrar de Alembic. Solo des-registrar de Alembic.
""" """
print("Ôä╣´©Å Tabla audit_logs NO ser├í eliminada (creada por schema.sql)") print(" Tabla audit_logs NO será eliminada (creada por schema.sql)")
print(" Modelo AuditLog des-registrado de Alembic") print(" Modelo AuditLog des-registrado de Alembic")

View File

@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "servicemanager-backend" name = "servicemanager-backend"
version = "1.5.1.2" version = "1.5.1"
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B" description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
authors = [ authors = [
{name = "Aduanasoft", email = "dev@aduanasoft.com"} {name = "Aduanasoft", email = "dev@aduanasoft.com"}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@servicemanager/client-frontend", "name": "@servicemanager/client-frontend",
"version": "1.5.1.2", "version": "1.5.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -3,6 +3,9 @@ import { auth } from './auth.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types // Types
export interface Category { export interface Category {
id: string; id: string;
@@ -30,7 +33,7 @@ const initialState: AppState = {
async function apiCall(endpoint: string, options: RequestInit = {}) { async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth); const authState = get(auth);
const response = await fetch(`/api/v1${endpoint}`, { const response = await fetch(`${API_URL}/v1${endpoint}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,6 +1,9 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types // Types
export interface User { export interface User {
id: string; id: string;
@@ -79,7 +82,7 @@ function createAuthStore() {
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_URL}/v1/auth/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -2,6 +2,9 @@ import type { Writable } from 'svelte/store';
import { get, writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import { auth } from './auth'; import { auth } from './auth';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types // Types
interface FastAPIValidationError { interface FastAPIValidationError {
loc: (string | number)[]; loc: (string | number)[];
@@ -84,7 +87,7 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
const response = await fetch(`/api/v1${endpoint}`, { const response = await fetch(`${API_URL}/v1${endpoint}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -259,7 +262,7 @@ function createTicketsStore() {
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, { const response = await fetch(`${API_URL}/v1/tickets/${ticketId}/attachments`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${authState.token}`, 'Authorization': `Bearer ${authState.token}`,
@@ -338,7 +341,7 @@ function createTicketsStore() {
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, { const response = await fetch(`${API_URL}/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${authState.token}`, 'Authorization': `Bearer ${authState.token}`,

View File

@@ -4,6 +4,9 @@
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
let currentPassword = ''; let currentPassword = '';
let newPassword = ''; let newPassword = '';
let confirmPassword = ''; let confirmPassword = '';
@@ -82,7 +85,7 @@
return; return;
} }
const response = await fetch('/api/v1/client-profile/', { const response = await fetch(`${API_URL}/v1/client-profile/`, {
headers: { headers: {
Authorization: `Bearer ${$auth.token}`, Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user.tenant_id 'X-Tenant-ID': $auth.user.tenant_id
@@ -185,7 +188,7 @@
isUpdatingProfile = true; isUpdatingProfile = true;
try { try {
const response = await fetch('/api/v1/auth/profile', { const response = await fetch(`${API_URL}/v1/auth/profile`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -218,7 +221,7 @@
isChangingPassword = true; isChangingPassword = true;
try { try {
const response = await fetch('/api/v1/auth/change-password', { const response = await fetch(`${API_URL}/v1/auth/change-password`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -269,7 +272,7 @@
profileData.credit_limit = parseFloat(profileData.credit_limit); profileData.credit_limit = parseFloat(profileData.credit_limit);
} }
const response = await fetch('/api/v1/client-profile/', { const response = await fetch(`${API_URL}/v1/client-profile/`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,6 +1,6 @@
{ {
"name": "@servicemanager/internal-frontend", "name": "@servicemanager/internal-frontend",
"version": "1.5.1.2", "version": "1.5.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,7 +1,6 @@
<script> <script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte'; import { createEventDispatcher, onMount, onDestroy } from 'svelte';
export let open = false;
export let title = ''; export let title = '';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -11,7 +10,7 @@
} }
function handleKeydown(e) { function handleKeydown(e) {
if (e.key === 'Escape' && open) { if (e.key === 'Escape') {
close(); close();
} }
} }
@@ -19,8 +18,7 @@
<svelte:window on:keydown={handleKeydown}/> <svelte:window on:keydown={handleKeydown}/>
{#if open} <div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> <div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true" on:click={close}></div> <div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true" on:click={close}></div>
@@ -38,7 +36,11 @@
</div> </div>
</div> </div>
</div> </div>
{#if $$slots.footer}
<div class="mt-5 sm:mt-6 sm:flex sm:flex-row-reverse">
<slot name="footer" />
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
{/if}

View File

@@ -1,6 +1,9 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types // Types
export interface InternalUser { export interface InternalUser {
id: string; id: string;
@@ -95,7 +98,7 @@ function createAuthStore() {
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_URL}/v1/auth/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -147,7 +150,7 @@ function createAuthStore() {
update (state => ({ ...state, isLoading: true })); update (state => ({ ...state, isLoading: true }));
try { try {
const response = await fetch('/api/v1/auth/refresh', { const response = await fetch(`${API_URL}/v1/auth/refresh`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,4 +1,4 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/utils/api'; import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast'; import { toast } from '$lib/stores/toast';
@@ -12,7 +12,7 @@
let selectedLog = null; let selectedLog = null;
let showDetailModal = false; let showDetailModal = false;
// Paginaci├│n // Paginación
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
let totalLogs = 0; let totalLogs = 0;
@@ -29,23 +29,23 @@
// Contador de filtros activos // Contador de filtros activos
$: activeFiltersCount = [filterUserId, filterAction, filterResourceType, filterDateFrom, filterDateTo, searchText].filter(f => f && f.trim()).length; $: activeFiltersCount = [filterUserId, filterAction, filterResourceType, filterDateFrom, filterDateTo, searchText].filter(f => f && f.trim()).length;
// Tipos de acciones y recursos (extra├¡dos de los logs) // Tipos de acciones y recursos (extraídos de los logs)
let availableActions = new Set<string>(); let availableActions = new Set<string>();
let availableResourceTypes = new Set<string>(); let availableResourceTypes = new Set<string>();
/** /**
* Cargar estad├¡sticas de auditor├¡a * Cargar estadísticas de auditoría
*/ */
async function loadStats() { async function loadStats() {
try { try {
stats = await api.get('/audit/stats'); stats = await api.get('/audit/stats');
} catch (e) { } catch (e) {
console.error('Error cargando estad├¡sticas:', e); console.error('Error cargando estadísticas:', e);
} }
} }
/** /**
* Cargar logs de auditor├¡a con filtros * Cargar logs de auditoría con filtros
*/ */
async function loadLogs() { async function loadLogs() {
isLoading = true; isLoading = true;
@@ -69,7 +69,7 @@
totalPages = response.total_pages; totalPages = response.total_pages;
currentPage = response.page; currentPage = response.page;
// Extraer acciones y tipos de recursos ├║nicos para los selectores // Extraer acciones y tipos de recursos únicos para los selectores
logs.forEach((log: any) => { logs.forEach((log: any) => {
availableActions.add(log.action); availableActions.add(log.action);
availableResourceTypes.add(log.resource_type); availableResourceTypes.add(log.resource_type);
@@ -98,7 +98,7 @@
} }
/** /**
* Aplicar filtros y recargar desde p├ígina 1 * Aplicar filtros y recargar desde página 1
*/ */
function applyFilters() { function applyFilters() {
currentPage = 1; currentPage = 1;
@@ -120,7 +120,7 @@
} }
/** /**
* Cambiar p├ígina * Cambiar página
*/ */
function goToPage(page: number) { function goToPage(page: number) {
if (page >= 1 && page <= totalPages) { if (page >= 1 && page <= totalPages) {
@@ -152,7 +152,7 @@
} }
/** /**
* Obtener color de badge seg├║n tipo de acci├│n * Obtener color de badge según tipo de acción
*/ */
function getActionColor(action: string): string { function getActionColor(action: string): string {
if (action.includes('login')) return 'bg-green-100 text-green-800'; if (action.includes('login')) return 'bg-green-100 text-green-800';
@@ -165,7 +165,7 @@
} }
/** /**
* Formatear acci├│n con informaci├│n del rol del usuario * Formatear acción con información del rol del usuario
*/ */
function formatActionWithRole(log: any): string { function formatActionWithRole(log: any): string {
const actionParts = log.action.split('.'); const actionParts = log.action.split('.');
@@ -174,14 +174,14 @@
const [resource, verb] = actionParts; const [resource, verb] = actionParts;
const verbMap: Record<string, string> = { const verbMap: Record<string, string> = {
'login': 'inici├│ sesi├│n', 'login': 'inició sesión',
'logout': 'cerr├│ sesi├│n', 'logout': 'cerró sesión',
'create': 'cre├│', 'create': 'creó',
'update': 'actualiz├│', 'update': 'actualizó',
'delete': 'elimin├│', 'delete': 'eliminó',
'assign': 'asign├│', 'assign': 'asignó',
'close': 'cerr├│', 'close': 'cerró',
'reopen': 'reabri├│' 'reopen': 'reabrió'
}; };
const roleMap: Record<string, string> = { const roleMap: Record<string, string> = {
@@ -219,14 +219,14 @@
<!-- Header --> <!-- Header -->
<div class="sm:flex sm:items-center sm:justify-between"> <div class="sm:flex sm:items-center sm:justify-between">
<div> <div>
<h1 class="text-2xl font-semibold text-gray-900">Auditor├¡a</h1> <h1 class="text-2xl font-semibold text-gray-900">Auditoría</h1>
<p class="mt-2 text-sm text-gray-700"> <p class="mt-2 text-sm text-gray-700">
Registro completo de todas las acciones realizadas en el sistema Registro completo de todas las acciones realizadas en el sistema
</p> </p>
</div> </div>
</div> </div>
<!-- Estad├¡sticas --> <!-- Estadísticas -->
{#if stats} {#if stats}
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> <div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div class="bg-white overflow-hidden shadow rounded-lg"> <div class="bg-white overflow-hidden shadow rounded-lg">
@@ -293,7 +293,7 @@
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="text-sm font-medium text-gray-500 truncate">Acci├│n m├ís Com├║n</dt> <dt class="text-sm font-medium text-gray-500 truncate">Acción más Común</dt>
<dd class="text-sm font-semibold text-gray-900 truncate"> <dd class="text-sm font-semibold text-gray-900 truncate">
{#if stats.top_actions && Object.keys(stats.top_actions).length > 0} {#if stats.top_actions && Object.keys(stats.top_actions).length > 0}
{Object.keys(stats.top_actions)[0]} {Object.keys(stats.top_actions)[0]}
@@ -324,7 +324,7 @@
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- B├║squeda de texto --> <!-- Búsqueda de texto -->
<div> <div>
<label for="search" class="block text-sm font-medium text-gray-700">Buscar</label> <label for="search" class="block text-sm font-medium text-gray-700">Buscar</label>
<input <input
@@ -353,9 +353,9 @@
</select> </select>
</div> </div>
<!-- Filtro por acci├│n --> <!-- Filtro por acción -->
<div> <div>
<label for="action" class="block text-sm font-medium text-gray-700">Acci├│n</label> <label for="action" class="block text-sm font-medium text-gray-700">Acción</label>
<select <select
id="action" id="action"
bind:value={filterAction} bind:value={filterAction}
@@ -415,7 +415,7 @@
<div class="mt-6 bg-white shadow rounded-lg overflow-hidden"> <div class="mt-6 bg-white shadow rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900"> <h3 class="text-lg font-medium text-gray-900">
Logs de Auditor├¡a Logs de Auditoría
<span class="text-sm text-gray-500 font-normal">({totalLogs} registros)</span> <span class="text-sm text-gray-500 font-normal">({totalLogs} registros)</span>
</h3> </h3>
</div> </div>
@@ -444,7 +444,7 @@
Usuario Usuario
</th> </th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Acci├│n Acción
</th> </th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Recurso Recurso
@@ -514,7 +514,7 @@
</table> </table>
</div> </div>
<!-- Paginaci├│n --> <!-- Paginación -->
{#if totalPages > 1} {#if totalPages > 1}
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> <div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="flex-1 flex justify-between sm:hidden"> <div class="flex-1 flex justify-between sm:hidden">
@@ -588,11 +588,11 @@
<!-- Modal de Detalle --> <!-- Modal de Detalle -->
{#if showDetailModal && selectedLog} {#if showDetailModal && selectedLog}
<Modal title="Detalle del Log de Auditor├¡a" on:close={() => showDetailModal = false}> <Modal title="Detalle del Log de Auditoría" on:close={() => showDetailModal = false}>
<div class="space-y-4"> <div class="space-y-4">
<!-- Informaci├│n General --> <!-- Información General -->
<div> <div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Informaci├│n General</h4> <h4 class="text-sm font-medium text-gray-900 mb-2">Información General</h4>
<dl class="grid grid-cols-2 gap-3 text-sm"> <dl class="grid grid-cols-2 gap-3 text-sm">
<div> <div>
<dt class="font-medium text-gray-500">ID:</dt> <dt class="font-medium text-gray-500">ID:</dt>
@@ -635,9 +635,9 @@
</dl> </dl>
</div> </div>
<!-- Acci├│n --> <!-- Acción -->
<div> <div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Acci├│n</h4> <h4 class="text-sm font-medium text-gray-900 mb-2">Acción</h4>
<div class="bg-gray-50 rounded-lg p-3"> <div class="bg-gray-50 rounded-lg p-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full {getActionColor(selectedLog.action)}"> <span class="px-2 py-1 text-xs font-semibold rounded-full {getActionColor(selectedLog.action)}">
{selectedLog.action} {selectedLog.action}

View File

@@ -145,7 +145,8 @@
</div> </div>
</div> </div>
<Modal open={showModal} title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}> {#if showModal}
<Modal title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label> <label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -182,3 +183,4 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}

View File

@@ -118,7 +118,8 @@
</div> </div>
</div> </div>
<Modal open={showModal} title={editingSystem ? 'Editar Sistema' : 'Nuevo Sistema'} on:close={() => showModal = false}> {#if showModal}
<Modal title={editingSystem ? 'Editar Sistema' : 'Nuevo Sistema'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label> <label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -145,3 +146,4 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}

View File

@@ -126,7 +126,8 @@
</div> </div>
</div> </div>
<Modal open={showModal} title={editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} on:close={() => showModal = false}> {#if showModal}
<Modal title={editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label> <label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -164,3 +165,4 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}

View File

@@ -522,7 +522,8 @@
</div> </div>
<!-- Modal Crear Ticket --> <!-- Modal Crear Ticket -->
<Modal open={showModal} title="Nuevo Ticket" on:close={() => showModal = false}> {#if showModal}
<Modal title="Nuevo Ticket" on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div> <div>
<label for="subject" class="block text-sm font-medium text-gray-700">Asunto *</label> <label for="subject" class="block text-sm font-medium text-gray-700">Asunto *</label>
@@ -608,9 +609,11 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}
<!-- Modal Editar Ticket --> <!-- Modal Editar Ticket -->
<Modal open={showEditModal} title="Editar Ticket #{selectedTicket?.ticket_number || ''}" on:close={() => showEditModal = false}> {#if showEditModal}
<Modal title="Editar Ticket #{selectedTicket?.ticket_number || ''}" on:close={() => showEditModal = false}>
<form on:submit|preventDefault={handleUpdate} class="space-y-4"> <form on:submit|preventDefault={handleUpdate} class="space-y-4">
<div> <div>
<label for="editStatus" class="block text-sm font-medium text-gray-700">Estado</label> <label for="editStatus" class="block text-sm font-medium text-gray-700">Estado</label>
@@ -674,9 +677,11 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}
<!-- Modal Eliminar Ticket --> <!-- Modal Eliminar Ticket -->
<Modal open={showDeleteModal} title="Eliminar Ticket" on:close={() => showDeleteModal = false}> {#if showDeleteModal}
<Modal title="Eliminar Ticket" on:close={() => showDeleteModal = false}>
<div class="space-y-4"> <div class="space-y-4">
<div class="bg-red-50 border border-red-200 rounded-md p-4"> <div class="bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex"> <div class="flex">
@@ -719,3 +724,4 @@
</div> </div>
</div> </div>
</Modal> </Modal>
{/if}

View File

@@ -172,7 +172,8 @@
</div> </div>
</div> </div>
<Modal open={showModal} title={editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} on:close={() => showModal = false}> {#if showModal}
<Modal title={editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
@@ -229,3 +230,4 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}