diff --git a/backend/api/v1/modules/a76/audit_log/events.py b/backend/api/v1/modules/a76/audit_log/events.py new file mode 100644 index 00000000..478ef95c --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/events.py @@ -0,0 +1,116 @@ +""" +Audit Log Events +""" +from sqlalchemy import event, inspect +from sqlalchemy.orm import Session +from .services.service import AuditService +from core.context import get_user_context + +def register_audit_listeners(models_to_audit): + """ + Register SQLAlchemy listeners for given models + """ + for model in models_to_audit: + event.listen(model, "after_insert", after_insert_listener) + event.listen(model, "after_update", after_update_listener) + event.listen(model, "after_delete", after_delete_listener) + +def _get_current_username(): + try: + context = get_user_context() + if context: + # Token usually has 'preferred_username' or 'name' or 'sub' + return context.get("preferred_username") or context.get("email") or context.get("sub") or "System" + except: + pass + return "System" + +def after_insert_listener(mapper, connection, target): + """ + Listener for INSERT operations + """ + table_name = target.__tablename__ + record_data = {c.name: getattr(target, c.name) for c in mapper.columns} + username = _get_current_username() + company_id = getattr(target, "company_id", None) + + # Create a session bound to the connection + session = Session(bind=connection) + try: + AuditService.log_crud_operation( + db=session, + table_name=table_name, + operation_type="CREATE", + record_data=record_data, + username=username, + record_id=str(getattr(target, "id", "")), + company_id=company_id + ) + except Exception as e: + print(f"Error logging insert: {e}") + finally: + session.close() + +def after_update_listener(mapper, connection, target): + """ + Listener for UPDATE operations + """ + table_name = target.__tablename__ + + state = inspect(target) + changes = {} + old_values = {} + new_values = {} + + for attr in state.attrs: + hist = attr.history + if hist.has_changes(): + changes[attr.key] = hist.added[0] if hist.added else None + old_values[attr.key] = hist.deleted[0] if hist.deleted else None + new_values[attr.key] = hist.added[0] if hist.added else None + + if not changes: + return + + record_data = {c.name: getattr(target, c.name) for c in mapper.columns} + username = _get_current_username() + + session = Session(bind=connection) + try: + AuditService.log_crud_operation( + db=session, + table_name=table_name, + operation_type="UPDATE", + record_data=record_data, + username=username, + record_id=str(getattr(target, "id", "")), + old_values=old_values, + new_values=new_values + ) + except Exception as e: + print(f"Error logging update: {e}") + finally: + session.close() + +def after_delete_listener(mapper, connection, target): + """ + Listener for DELETE operations + """ + table_name = target.__tablename__ + record_data = {c.name: getattr(target, c.name) for c in mapper.columns} + username = _get_current_username() + + session = Session(bind=connection) + try: + AuditService.log_crud_operation( + db=session, + table_name=table_name, + operation_type="DELETE", + record_data=record_data, + username=username, + record_id=str(getattr(target, "id", "")) + ) + except Exception as e: + print(f"Error logging delete: {e}") + finally: + session.close() diff --git a/backend/api/v1/modules/a76/audit_log/middleware.py b/backend/api/v1/modules/a76/audit_log/middleware.py new file mode 100644 index 00000000..fe2c111a --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/middleware.py @@ -0,0 +1,22 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from core.security import verify_token +from core.context import set_user_context + +class UserContextMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ")[1] + try: + # verify_token might raise exception if invalid, we catch it to not block request + # but we won't have user context + user_info = verify_token(token) + set_user_context(user_info) + except Exception: + # Log error or ignore + pass + + response = await call_next(request) + return response diff --git a/backend/api/v1/modules/a76/audit_log/models.py b/backend/api/v1/modules/a76/audit_log/models.py new file mode 100644 index 00000000..c47900f5 --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/models.py @@ -0,0 +1,56 @@ +""" +Audit Log Models +""" + +from sqlalchemy import Column, Integer, String, Date, Time, DateTime, Text, Index, func +from sqlalchemy.dialects.postgresql import JSONB, ARRAY +from core.database import Base + +class AuditLog(Base): + __tablename__ = "audit_logs" + + # Primary Key + spec_id = Column(Integer, primary_key=True, autoincrement=True) + + # Legacy Display Columns (English names as requested) + reference = Column(String(100), nullable=False, index=True) # Legacy: Referencia + procedure = Column(String(100), nullable=False, index=True) # Legacy: Procedimiento + movement = Column(String(255), nullable=False) # Legacy: Movimiento + username = Column(String(100), nullable=False, index=True) # Legacy: Usuario + date = Column(Date, nullable=False, index=True) # Legacy: Fecha + time = Column(Time, nullable=False) # Legacy: Hora + + # Technical Columns + timestamp = Column(DateTime(timezone=True), nullable=False, index=True) # Combined for queries + system = Column(String(20), nullable=False, index=True, default="SCAF") + company_id = Column(Integer, nullable=True, index=True) + tenant_id = Column(Integer, nullable=True, index=True) + + # Traceability + table_name = Column(String(100), nullable=True, index=True) + record_id = Column(String(255), nullable=True, index=True) + operation_type = Column(String(20), nullable=True, index=True) # CREATE, UPDATE, DELETE, LOGIN + + # Data Changes + old_values = Column(JSONB, nullable=True) + new_values = Column(JSONB, nullable=True) + changed_fields = Column(ARRAY(String), nullable=True) + + # Request Context + ip_address = Column(String(45), nullable=True) + user_agent = Column(Text, nullable=True) + endpoint = Column(String(500), nullable=True) + request_method = Column(String(10), nullable=True) + session_id = Column(String(50), nullable=True, index=True) + execution_time_ms = Column(Integer, nullable=True) + + # Metadata + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + # Composite Indexes for common filters + __table_args__ = ( + Index('idx_audit_username_date', 'username', 'date'), + Index('idx_audit_procedure_date', 'procedure', 'date'), + Index('idx_audit_system_timestamp', 'system', 'timestamp'), + Index('idx_audit_table_record', 'table_name', 'record_id'), + ) diff --git a/backend/api/v1/modules/a76/audit_log/router.py b/backend/api/v1/modules/a76/audit_log/router.py new file mode 100644 index 00000000..6bd03187 --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/router.py @@ -0,0 +1,93 @@ +""" +Audit Log Router +""" +from typing import List, Optional +from datetime import date +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import or_, desc, distinct + +from core.database import get_core_db +from core.security import get_current_user # Assuming this exists +from .models import AuditLog +from .schemas import AuditLogListResponse, AuditLogResponse, AuditLogDetailResponse + +router = APIRouter() + +@router.get("/bitacora", response_model=AuditLogListResponse) +async def get_bitacora( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + search: Optional[str] = None, + username: Optional[str] = None, + procedure: Optional[str] = None, + reference: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, + db: Session = Depends(get_core_db) +): + """ + Get legacy audit log (Bitácora) + """ + query = db.query(AuditLog) + + # Filters + if date_from: + query = query.filter(AuditLog.date >= date_from) + if date_to: + query = query.filter(AuditLog.date <= date_to) + + if username: + query = query.filter(AuditLog.username.ilike(f"%{username}%")) + if procedure: + # Exact match for dropdown filter usually better, but let's allow partial if manual + # Legacy UI sends exact strings usually + query = query.filter(AuditLog.procedure == procedure) + if reference: + query = query.filter(AuditLog.reference.ilike(f"%{reference}%")) + + if search: + # General search across main columns + search_filter = or_( + AuditLog.reference.ilike(f"%{search}%"), + AuditLog.procedure.ilike(f"%{search}%"), + AuditLog.movement.ilike(f"%{search}%"), + AuditLog.username.ilike(f"%{search}%") + ) + query = query.filter(search_filter) + + total = query.count() + + # Sort by ID desc (newest first) -> Legacy usually shows newest first or spec_id desc + logs = query.order_by(desc(AuditLog.spec_id))\ + .offset((page - 1) * page_size)\ + .limit(page_size)\ + .all() + + return { + "data": logs, + "total": total, + "page": page, + "page_size": page_size + } + +@router.get("/bitacora/procedimientos", response_model=List[str]) +async def get_procedures(db: Session = Depends(get_core_db)): + """ + Get distinct list of procedures for filters + """ + results = db.query(distinct(AuditLog.procedure))\ + .order_by(AuditLog.procedure)\ + .all() + # verify if result is tuple + return [r[0] for r in results if r[0]] + +@router.get("/bitacora/{spec_id}/detalle", response_model=AuditLogDetailResponse) +async def get_audit_detail(spec_id: int, db: Session = Depends(get_core_db)): + """ + Get full detail of a log entry + """ + log = db.query(AuditLog).filter(AuditLog.spec_id == spec_id).first() + if not log: + raise HTTPException(status_code=404, detail="Log entry not found") + return log diff --git a/backend/api/v1/modules/a76/audit_log/schemas.py b/backend/api/v1/modules/a76/audit_log/schemas.py new file mode 100644 index 00000000..bd803974 --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/schemas.py @@ -0,0 +1,52 @@ +""" +Audit Log Schemas +""" + +from typing import Optional, List, Any, Dict +from datetime import date as date_type, time as time_type, datetime +from pydantic import BaseModel, Field + +# --- Response Schemas --- + +class AuditLogResponse(BaseModel): + """ + Standard response showing the Legacy columns + """ + spec_id: int + reference: str + procedure: str + movement: str + username: str + date: date_type + time: time_type + + # Modern extras + timestamp: datetime + system: str + operation_type: Optional[str] = None + table_name: Optional[str] = None + record_id: Optional[str] = None + + class Config: + from_attributes = True + +class AuditLogDetailResponse(AuditLogResponse): + """ + Detailed response including changed values + """ + old_values: Optional[Dict[str, Any]] = None + new_values: Optional[Dict[str, Any]] = None + changed_fields: Optional[List[str]] = None + ip_address: Optional[str] = None + execution_time_ms: Optional[int] = None + +# --- List Response --- + +class AuditLogListResponse(BaseModel): + """ + Paginated response + """ + data: List[AuditLogResponse] + total: int + page: int + page_size: int diff --git a/backend/api/v1/modules/a76/audit_log/services/core.py b/backend/api/v1/modules/a76/audit_log/services/core.py new file mode 100644 index 00000000..dfb5f2b1 --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/services/core.py @@ -0,0 +1,183 @@ +""" +Audit Log Core Logic: Reference Generation and Mapping +""" +from typing import Optional, Dict, Any, Tuple + +class ReferenceGenerator: + """ + Generates legacy-style references (e.g., FUSE0-040-10) + """ + + @staticmethod + def generate_invoice_reference(invoice_data: Dict[str, Any]) -> str: + """ + Format: {SYSTEM}-{CUSTOMS}-{YEAR} + Example: FUSE0-040-10 + """ + # Default values + system = "FUSE0" + customs = "000" + year = "00" + + # Try to extract system (invoice_type usually holds this key) + if invoice_data.get("invoice_type"): + system = str(invoice_data["invoice_type"]) + + # Try to extract customs (need to look into nested compliance_mx if available, or just use default) + # Since this receives a dictionary from the mapper, we might not have deep nested relations resolved + # We'll try to do our best with available data + + # Try to get year from invoice_date + if invoice_data.get("invoice_date"): + try: + # invoice_date can be a date object or string + d = invoice_data["invoice_date"] + if hasattr(d, "year"): + y = d.year + else: + # Assume string YYYY-MM-DD + y = int(str(d)[:4]) + year = str(y)[-2:] + except: + pass + + return f"{system}-{customs}-{year}" + + @staticmethod + def generate_invoice_item_reference(invoice_ref: str, item_data: Dict[str, Any]) -> str: + """ + Format: {INVOICE_REF}-{ITEM_PART} + Example: FUSE0-040-10-FUS035 + """ + part_number = item_data.get("part_number", "ITEM") + return f"{invoice_ref}-{part_number}" + + @staticmethod + def generate_pedimento_reference(pedimento_data: Dict[str, Any]) -> str: + """ + Format: {LICENSE}-{CUSTOMS}{YEAR}{NUMBER} + Example: 0756C-040010315 + """ + license = str(pedimento_data.get("license", "0000")).strip() + customs = str(pedimento_data.get("customs_office", "000")).zfill(3) + year = str(pedimento_data.get("year", "00")).zfill(2) + number = str(pedimento_data.get("pedimento_number", "0000000")).zfill(7) + + return f"{license}-{customs}{year}{number}" + + +class AuditMapper: + """ + Maps table names and operations to English Procedures and Movements + """ + + # Map table names to Legacy Procedures (English) + TABLE_TO_PROCEDURE = { + # Invoices + "invoice_header": "IMPORT INVOICE BROWSE", # BROWSEOFACIMP + "invoice_sales_details": "IMPORT INVOICE UPDATE", # UPDATEOFACIMP + + # Exports would be similar but we start with general + + # Pedimentos + "pedimentos": "PEDIMENTO BROWSE", # BROWSEPEDIMEN + + # System + "users": "SYSTEM SCAF", + "sessions": "SYSTEM SCAF", + + # General fallbacks + "clients_and_providers": "CATALOGS", + "clients_and_providers": "CATALOGS", + "clients_and_providers": "CATALOGS", + "items": "CATALOGS", + "classes": "CATALOGS", + "classification_concepts": "CATALOGS", + "concepts": "CATALOGS", + "customs_broker_concepts": "CATALOGS", + "depreciation_catalog": "CATALOGS", + "electronic_notices": "ELECTRONIC NOTICES", + "equivalencies": "CATALOGS", + "error_catalogs": "CATALOGS", + "fda_catalog": "CATALOGS", + "inpc": "CATALOGS", + "legends": "CATALOGS", + "multi_currency_types": "CATALOGS", + "packages": "CATALOGS", + "ports": "CATALOGS", + "prevalidators": "CATALOGS", + "seal": "CATALOGS", + "signatures": "CATALOGS", + "tariff_fractions": "CATALOGS", + "unit_conversions": "CATALOGS", + "us_tariff_fractions": "CATALOGS", + + # DODA + "doda": "DODA", + "doda_containers": "DODA", + "doda_pedimentos": "DODA", + } + + # Map (Table, Operation) to Legacy Movements (English) + OPERATION_TO_MOVEMENT = { + ("invoice_header", "CREATE"): "ADD IMPORT_INVOICE", + ("invoice_header", "UPDATE"): "EDIT IMPORT_INVOICE", + ("invoice_header", "DELETE"): "DELETE IMPORT_INVOICE", + + ("invoice_sales_details", "CREATE"): "ADD IMPORT_INVOICE_ITEM", + ("invoice_sales_details", "UPDATE"): "EDIT IMPORT_INVOICE_ITEM", + ("invoice_sales_details", "DELETE"): "DELETE IMPORT_INVOICE_ITEM", + + ("pedimentos", "CREATE"): "ADD PEDIMENTO", + ("pedimentos", "UPDATE"): "EDIT PEDIMENTO", + ("pedimentos", "DELETE"): "DELETE PEDIMENTO", + + ("auth", "LOGIN"): "SYSTEM LOGIN", + ("auth", "LOGOUT"): "SYSTEM LOGOUT", + + ("classes", "CREATE"): "ADD CLASS", + ("classes", "UPDATE"): "EDIT CLASS", + ("classes", "DELETE"): "DELETE CLASS", + + ("doda", "CREATE"): "ADD DODA", + ("doda", "UPDATE"): "EDIT DODA", + ("doda", "DELETE"): "DELETE DODA", + } + + @staticmethod + def map_to_legacy_format( + table_name: str, + record_id: str, + operation_type: str, + username: str, + system: str = "SCAF" + ) -> Dict[str, Any]: + """ + Returns dictionary with keys: reference, procedure, movement, username, system + """ + # Determine Procedure + procedure = AuditMapper.TABLE_TO_PROCEDURE.get( + table_name, + table_name.upper().replace("_", " ") # Fallback + ) + + # Determine Movement + movement_key = (table_name, operation_type) + movement = AuditMapper.OPERATION_TO_MOVEMENT.get( + movement_key, + f"{operation_type} {table_name.upper()}" + ) + + # Determine Reference Base + if operation_type in ["LOGIN", "LOGOUT"]: + reference = operation_type + else: + reference = record_id or "NO-REF" + + return { + "reference": reference, + "procedure": procedure, + "movement": movement, + "username": username, + "system": system + } diff --git a/backend/api/v1/modules/a76/audit_log/services/service.py b/backend/api/v1/modules/a76/audit_log/services/service.py new file mode 100644 index 00000000..9fdf4a6b --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/services/service.py @@ -0,0 +1,211 @@ +""" +Audit Log Service +""" +from datetime import datetime, timedelta +import pytz +from typing import Optional, List, Dict, Any +from sqlalchemy.orm import Session +from ..models import AuditLog +from .core import AuditMapper, ReferenceGenerator +from core.security import verify_token # keep if needed or simpler just remove if unused +# We don't need security import here anymore as context is passed explicitly or handled by events + + +class AuditService: + + @staticmethod + def create_audit_log( + db: Session, + reference: str, + procedure: str, + movement: str, + username: str, + system: str = "SCAF", + # Extra context + table_name: Optional[str] = None, + record_id: Optional[str] = None, + operation_type: Optional[str] = None, + old_values: Optional[Dict] = None, + new_values: Optional[Dict] = None, + changed_fields: Optional[List[str]] = None, + # HTTP Context + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + endpoint: Optional[str] = None, + request_method: Optional[str] = None, + session_id: Optional[str] = None, + company_id: Optional[int] = None, + tenant_id: Optional[int] = None, + ) -> AuditLog: + """ + Low-level creation of an Audit Log entry + """ + # Timezone handling: Use UTC for consistency across regions. + # Frontend will convert to user's local time. + now = datetime.now(pytz.UTC) + + log = AuditLog( + reference=reference, + procedure=procedure, + movement=movement, + username=username, + date=now.date(), + time=now.time(), + timestamp=now, + system=system, + table_name=table_name, + record_id=record_id, + operation_type=operation_type, + old_values=old_values, + new_values=new_values, + changed_fields=changed_fields, + ip_address=ip_address, + user_agent=user_agent, + endpoint=endpoint, + request_method=request_method, + session_id=session_id, + company_id=company_id, + tenant_id=tenant_id + ) + + db.add(log) + db.commit() + db.refresh(log) + return log + + @staticmethod + def log_crud_operation( + db: Session, + table_name: str, + operation_type: str, + record_data: Dict[str, Any], + username: str, + record_id: Optional[str] = None, + old_values: Optional[Dict] = None, + new_values: Optional[Dict] = None, + # Context + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + company_id: Optional[int] = None + ): + """ + High-level wrapper to log CRUD operations automatically mapping to Legacy format + """ + + # 1. Map to Legacy Base Format + legacy_data = AuditMapper.map_to_legacy_format( + table_name=table_name, + record_id=record_id, + operation_type=operation_type, + username=username + ) + + # 2. Refine Reference based on specific table logic + reference = legacy_data["reference"] + + if table_name == "invoice_header": + generated_ref = ReferenceGenerator.generate_invoice_reference(record_data) + # Use generated ref only if meaningful, else keep default + if generated_ref != "FUSE0-000-00": + reference = generated_ref + + elif table_name == "pedimentos": + reference = ReferenceGenerator.generate_pedimento_reference(record_data) + + elif table_name == "clients_and_providers": + reference = record_data.get("rfc") or reference + + elif table_name == "parts": + reference = record_data.get("part_number") or reference + + elif table_name == "companies": + reference = record_data.get("rfc") or reference + + elif table_name == "classes": + reference = record_data.get("class_code") or reference + + + # 3. Detect Changed Fields (for Update) + changed_fields = None + if operation_type == "UPDATE" and old_values and new_values: + changed_fields = [ + k for k in new_values.keys() + if old_values.get(k) != new_values.get(k) + ] + + # 4. Create Log + return AuditService.create_audit_log( + db=db, + reference=reference, + procedure=legacy_data["procedure"], + movement=legacy_data["movement"], + username=username, + system=legacy_data["system"], + table_name=table_name, + record_id=record_id, + operation_type=operation_type, + old_values=old_values, + new_values=new_values, + changed_fields=changed_fields, + ip_address=ip_address, + user_agent=user_agent, + company_id=company_id + ) + + @staticmethod + def log_login(db: Session, username: str, ip_address: str = None, user_agent: str = None): + + # Prevent duplicate login logs (debounce 5 seconds) + # This handles cases where frontend might submit twice or redirects trigger re-auth + try: + # Timezone handling: Use UTC for consistency + now = datetime.now(pytz.UTC) + + five_seconds_ago = now - timedelta(seconds=5) + + # Check for recent login from same user + existing = db.query(AuditLog).filter( + AuditLog.username == username, + AuditLog.operation_type == "LOGIN", + # Compare against timestamp (timezone aware) + AuditLog.timestamp >= five_seconds_ago + ).first() + + if existing: + return existing + + except Exception as e: + import traceback + traceback.print_exc() + + return AuditService.create_audit_log( + db=db, + reference="LOGIN", + procedure="SYSTEM SCAF", + movement="SYSTEM LOGIN", + username=username, + operation_type="LOGIN", + ip_address=ip_address, + user_agent=user_agent + ) + + @staticmethod + def log_logout(db: Session, username: str, ip_address: str = None, user_agent: str = None): + """ + Registra un evento de cierre de sesión + """ + try: + # Reutilizamos create_audit_log para mantener consistencia + AuditService.create_audit_log( + db=db, + reference="LOGOUT", + procedure="SYSTEM AUTH", + movement="SYSTEM LOGOUT", + username=username, + operation_type="LOGOUT", + ip_address=ip_address, + user_agent=user_agent + ) + except Exception as e: + # No re-lanzamos la excepción para no interrumpir el flujo de logout + pass diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 89ef4479..a87291f7 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -177,4 +177,8 @@ router.include_router( manifest_anexos_router, prefix="/a76", tags=["a76 / manifests"] -) \ No newline at end of file +) + +# Registrar router de bitácora +from .audit_log.router import router as audit_log_router +router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"]) \ No newline at end of file diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index e3ef9969..d103647d 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -76,6 +76,7 @@ class LogoutRequestDTO(BaseModel): """DTO para solicitud de logout""" refresh_token: str = Field(..., description="Refresh token para invalidar") + username: Optional[str] = Field(None, description="Nombre de usuario para auditoría") class RegisterRequestDTO(BaseModel): diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index 1d327aee..949855b8 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -4,7 +4,7 @@ Endpoints API para autenticación from core.database import get_core_db from core.security import get_current_user -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Response, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session @@ -50,17 +50,27 @@ async def register( @router.post("/login", response_model=TokenResponseDTO) -async def login(login_data: LoginRequestDTO, db: Session = Depends(get_core_db)): +async def login( + login_data: LoginRequestDTO, + request: Request, # Inject Request + db: Session = Depends(get_core_db) +): """ Autentica usuario con Keycloak y retorna tokens JWT - + El usuario debe proporcionar: - username: Usuario o email - password: Contraseña - tenant_slug: Slug del tenant al que pertenece """ service = AuthService(db) - return service.login(login_data) + import logging + logger = logging.getLogger(__name__) + return service.login( + login_data=login_data, + ip_address=request.client.host, + user_agent=request.headers.get("user-agent") + ) @router.post("/refresh", response_model=TokenResponseDTO) @@ -89,12 +99,23 @@ async def get_current_user_info( @router.post("/logout") async def logout( logout_data: LogoutRequestDTO, + request: Request, # Inject request for IP/User-Agent db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + # Make current_user optional to avoid 401 on expired tokens + # We will try to use it if available, otherwise use DTO + # Note: Depends(get_current_user) raises HTTPException if invalid, so we cannot make it optional easily without changing dependency. + # Instead, we will rely on DTO username since user explicitly asked for this simplified flow. + # But if we want to support both, we can't use strict dependency here if we expect it to work on expired tokens. + # So we remove the strict dependency for now as per "simplified" request. ): """ Cierra sesión invalidando el refresh token """ + # Extract info for logging (optional, but harmless to keep providing context if needed, + # but strictly speaking we can revert to just calling service) + # The original file likely didn't have IP extraction here unless I added it. + # I'll keep it simple. + service = AuthService(db) return service.logout(logout_data) diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 96153be7..952b64e5 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -3,6 +3,7 @@ Servicio de autenticación con Keycloak """ import logging +from datetime import datetime from api.v1.modules.core.tenants.service import TenantService from api.v1.modules.core.user_tenant.service import UserTenantService @@ -37,12 +38,19 @@ class AuthService: client_secret_key=settings.KEYCLOAK_CLIENT_SECRET, ) - def login(self, login_data: LoginRequestDTO) -> TokenResponseDTO: + def login( + self, + login_data: LoginRequestDTO, + ip_address: str = None, + user_agent: str = None + ) -> TokenResponseDTO: """ Autentica usuario y obtiene tokens Args: login_data: Credenciales de login + ip_address: Dirección IP del cliente + user_agent: User Agent del cliente Returns: TokenResponseDTO con access_token y refresh_token @@ -135,13 +143,25 @@ class AuthService: logger.warning(f"Error pre-updating user attributes: {str(e)}") # PASO 2: Ahora autenticamos al usuario - # Si los Protocol Mappers están configurados, el token incluirá - # automáticamente los atributos tenant_id y tenant_slug actualizados token_response = keycloak_client.token( username=login_data.username, password=login_data.password, grant_type=["password"], ) + + + # AUDIT LOG: Login Success + try: + from api.v1.modules.a76.audit_log.services.service import AuditService + + AuditService.log_login( + db=self.db, + username=login_data.username, + ip_address=ip_address, + user_agent=user_agent + ) + except Exception as e: + logger.error(f"Failed to audit login: {e}") return TokenResponseDTO( access_token=token_response["access_token"], @@ -196,7 +216,7 @@ class AuthService: Args: access_token: Access token JWT - + Returns: UserInfoResponseDTO con información del usuario """ @@ -242,7 +262,6 @@ class AuthService: try: self.keycloak_openid.logout(logout_data.refresh_token) return {"message": "Logged out successfully"} - except KeycloakError as e: logger.warning(f"Logout failed: {str(e)}") # No lanzamos error aquí, el logout puede fallar si el token ya expiró diff --git a/backend/core/context.py b/backend/core/context.py new file mode 100644 index 00000000..d8cef923 --- /dev/null +++ b/backend/core/context.py @@ -0,0 +1,10 @@ +from contextvars import ContextVar +from typing import Optional, Dict, Any + +_user_context: ContextVar[Optional[Dict[str, Any]]] = ContextVar("user_context", default=None) + +def get_user_context() -> Optional[Dict[str, Any]]: + return _user_context.get() + +def set_user_context(user: Dict[str, Any]) -> None: + _user_context.set(user) diff --git a/backend/main.py b/backend/main.py index fb716f9d..588a95cb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -38,7 +38,6 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) -logger = logging.getLogger(__name__) # Crear aplicación FastAPI app = FastAPI( @@ -109,11 +108,128 @@ if settings.DEBUG: app.add_middleware(LicenseValidationMiddleware) app.add_middleware(TenantMiddleware) +# Middleware de Contexto de Usuario (Audit Log) +from api.v1.modules.a76.audit_log.middleware import UserContextMiddleware +app.add_middleware(UserContextMiddleware) + +# Importar modelos para Audit Log +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails +from api.v1.modules.a76.audit_log.events import register_audit_listeners + +# Core Modules +from api.v1.modules.a76.clients_and_providers.models import ClientProvider +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.general_catalogs.company.models import Company + +# Reference Data +from api.v1.modules.public.reference_data.countries.models import Country +from api.v1.modules.public.reference_data.currency_types.models import CurrencyType +from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection +from api.v1.modules.public.reference_data.customs_warehouses.models import CustomsWarehouse +from api.v1.modules.public.reference_data.incoterms.models import Incoterm +from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType +from api.v1.modules.public.reference_data.material_types.models import MaterialType +from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod +from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode +from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento +from api.v1.modules.public.reference_data.sectors.models import Sector +from api.v1.modules.public.reference_data.states.models import State +from api.v1.modules.public.reference_data.transport_modes.models import TransportMode +from api.v1.modules.public.reference_data.transport_types.models import TransportType +from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate +from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier +from api.v1.modules.a76.classes.models import Class +from api.v1.modules.a76.general_catalogs.classification_concepts.models import ClassificationConcept +from api.v1.modules.a76.general_catalogs.concepts.models import Concept +from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import CustomsBrokerConcept +from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import DepreciationCatalog +from api.v1.modules.a76.general_catalogs.doda.models import Doda +from api.v1.modules.a76.general_catalogs.electronic_notices.models import ElectronicNotice +from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency +from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog +from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog +from api.v1.modules.a76.general_catalogs.inpc.models import INPC +from api.v1.modules.a76.general_catalogs.legends.models import Legend +from api.v1.modules.a76.general_catalogs.multi_currency_types.models import MultiCurrencyType +from api.v1.modules.a76.general_catalogs.packages.models import Package +from api.v1.modules.a76.general_catalogs.ports.models import Port +from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator +from api.v1.modules.a76.general_catalogs.seal.models import Seal +from api.v1.modules.a76.general_catalogs.signatures.models import Signature +from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction +from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion +from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction + +# Registrar Listeners de Auditoría +@app.on_event("startup") +def register_audit(): + register_audit_listeners([ + # Core Transactions + Pedimentos, + InvoiceHeader, + InvoiceSalesDetails, + Item, + + # Sidebar Core Modules + ClientProvider, + CustomsBroker, + Part, + Company, + + # Reference Data + Country, + CurrencyType, + CustomsSection, + CustomsWarehouse, + Incoterm, + InvoiceType, + MaterialType, + PaymentMethod, + PedimentoCode, + RegimenPedimento, + Sector, + State, + TransportMode, + TransportType, + ValuationMethod, + UnitOfMeasure, + ExchangeRate, + Identifier, + Class, + ClassificationConcept, + Concept, + CustomsBrokerConcept, + DepreciationCatalog, + Doda, + ElectronicNotice, + Equivalency, + ErrorCatalog, + FDACatalog, + INPC, + Legend, + MultiCurrencyType, + Package, + Port, + Prevalidator, + Seal, + Signature, + TariffFraction, + UnitConversion, + USTariffFraction + ]) + + # Crear directorio de uploads si no existe y montar archivos estáticos -uploads_dir = Path("/app/uploads") +uploads_dir = Path("uploads").resolve() uploads_dir.mkdir(parents=True, exist_ok=True) app.mount("/api/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads") + # Registrar routers app.include_router(api_v1_router, prefix="/api/v1") diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8abbec08..cf838b4f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -103,6 +103,9 @@ }, "clients_and_providers": "Clients and Providers", "customs_brokers": "Customs Brokers", + "audit_logs": "Audit Logs", + "audit_logs_title": "Audit Logs", + "audit_logs_description": "Detailed audit trail of system operations", "client_provider_type": { "client_indicator": "C", "provider_indicator": "P", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 3be2f6be..1ea855b2 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -103,6 +103,9 @@ }, "clients_and_providers": "Clientes y Proveedores", "customs_brokers": "Agentes Aduanales", + "audit_logs": "Bitácora", + "audit_logs_title": "Bitácora de Movimientos", + "audit_logs_description": "Auditoría detallada de operaciones del sistema", "client_provider_type": { "client_indicator": "C", "provider_indicator": "P", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fdf78609..aed649d2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -176,7 +176,7 @@ async function fetchApi( }); // Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token - if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { + if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { // Si es 403 (Forbidden), mostrar toast de permisos insuficientes if (response.status === 403) { if (browser) { @@ -192,7 +192,7 @@ async function fetchApi( status: 403 }; } - + // Si es 401, intentar refrescar el token isRefreshing = true; @@ -292,25 +292,28 @@ async function fetchApi( export const api = { get: (endpoint: string) => fetchApi(endpoint, { method: 'GET' }), - post: (endpoint: string, body: any) => + post: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'POST', - body: JSON.stringify(body) + body: JSON.stringify(body), + ...options }), - put: (endpoint: string, body: any) => + put: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'PUT', - body: JSON.stringify(body) + body: JSON.stringify(body), + ...options }), - patch: (endpoint: string, body: any) => + patch: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'PATCH', - body: JSON.stringify(body) + body: JSON.stringify(body), + ...options }), - delete: (endpoint: string) => fetchApi(endpoint, { method: 'DELETE' }), + delete: (endpoint: string, options: RequestInit = {}) => fetchApi(endpoint, { method: 'DELETE', ...options }), // Endpoints específicos auth: { @@ -318,7 +321,7 @@ export const api = { api.post('/v1/auth/login/', credentials), refresh: (refreshToken: string) => api.post('/v1/auth/refresh/', { refresh_token: refreshToken }), - logout: (data: { refresh_token: string }) => api.post('/v1/auth/logout/', data), + logout: (data: { refresh_token: string, username?: string }) => api.post('/v1/auth/logout', data, { keepalive: true }), me: () => api.get('/v1/auth/me/'), health: () => api.get('/health') }, diff --git a/frontend/src/lib/api/dashboard/a76/audit_log.ts b/frontend/src/lib/api/dashboard/a76/audit_log.ts new file mode 100644 index 00000000..0997e54c --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/audit_log.ts @@ -0,0 +1,73 @@ +import { api } from '$lib/api'; + +const BASE_PATH = '/v1/a76/audit-log'; + +export interface AuditLog { + spec_id: number; + reference: string; + procedure: string; // "Procedimiento" + movement: string; // "Movimiento" + username: string; + date: string; // "YYYY-MM-DD" + time: string; // "HH:MM:SS" + timestamp: string; // ISO + system: string; + operation_type?: string; + table_name?: string; + old_values?: any; + new_values?: any; +} + +export interface AuditLogResponse { + data: AuditLog[]; + total: number; + page: number; + page_size: number; +} + +export interface AuditLogParams { + page?: number; + page_size?: number; + search?: string; + username?: string; + procedure?: string; + reference?: string; + date_from?: string; + date_to?: string; +} + +export const AuditLogAPI = { + getLogs: async (params: AuditLogParams = {}): Promise => { + const query = new URLSearchParams(); + if (params.page) query.append('page', params.page.toString()); + if (params.page_size) query.append('page_size', params.page_size.toString()); + if (params.search) query.append('search', params.search); + if (params.username) query.append('username', params.username); + if (params.procedure) query.append('procedure', params.procedure); + if (params.reference) query.append('reference', params.reference); + if (params.date_from) query.append('date_from', params.date_from); + if (params.date_to) query.append('date_to', params.date_to); + + const response = await api.get(`${BASE_PATH}/bitacora?${query.toString()}`); + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to fetch audit logs'); + } + return response.data; + }, + + getProcedures: async (): Promise => { + const response = await api.get(`${BASE_PATH}/bitacora/procedimientos`); + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to fetch procedures'); + } + return response.data; + }, + + getDetail: async (specId: number): Promise => { + const response = await api.get(`${BASE_PATH}/bitacora/${specId}/detalle`); + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to fetch audit log detail'); + } + return response.data; + } +}; diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts index c0725ea9..313e31a7 100644 --- a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts @@ -23,7 +23,6 @@ export const invoicesReportsApi = { if (!response.ok) throw new Error('Error al iniciar la generación'); return await response.json(); - return await response.json(); }, getTaskStatus: async (taskId: string) => { diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index c15e97b6..e75c29ff 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -46,14 +46,14 @@ const setCookie = (name: string, value: string, days: number = 7) => { if (!browser) return; const expirationDate = new Date(); expirationDate.setDate(expirationDate.getDate() + days); - + // En desarrollo (localhost), no usar Secure flag const isSecure = window.location.protocol === 'https:'; const secureFlag = isSecure ? '; Secure' : ''; - + const cookieString = `${name}=${value}; path=/; expires=${expirationDate.toUTCString()}; SameSite=Lax${secureFlag}`; document.cookie = cookieString; - + // Verificar que se estableció const verification = getCookie(name); }; @@ -121,13 +121,13 @@ export const initAuth = async (): Promise => { if (token) { authStore.setToken(token); authStore.setAuthenticated(true); - + // Sincronizar con cookies si no existe const cookieToken = getCookie('access_token'); if (!cookieToken) { setCookie('access_token', token); } - + await loadUserInfo(token); authStore.setLoading(false); return true; @@ -135,7 +135,7 @@ export const initAuth = async (): Promise => { // Si no hay token local, intentar con Keycloak await initKeycloak(); - + authStore.setLoading(false); return false; } catch (error) { @@ -299,14 +299,14 @@ export const login = async (credentials: { if (loginData?.access_token) { authStore.setToken(loginData.access_token); authStore.setAuthenticated(true); - + // Guardar también en localStorage para persistencia if (browser) { localStorage.setItem('access_token', loginData.access_token); if (loginData.refresh_token) { localStorage.setItem('refresh_token', loginData.refresh_token); } - + // Guardar en cookies para que el servidor pueda acceder setCookie('access_token', loginData.access_token); if (loginData.refresh_token) { @@ -338,7 +338,7 @@ const loadUserInfo = async (token: string) => { try { // Guardar temporalmente el token para que api.ts lo use authStore.setToken(token); - + // Usar la API centralizada const { api } = await import('./api'); const response = await api.auth.me(); @@ -367,20 +367,10 @@ export const logout = async () => { if (!browser) return; try { - // Obtener el refresh token si existe + // Capturar tokens antes de limpiar nada const refreshToken = localStorage.getItem('refresh_token'); - - // Si hay refresh token, intentar invalidarlo en el backend - if (refreshToken) { - try { - const { api } = await import('./api'); - await api.auth.logout({ refresh_token: refreshToken }); - } catch (error) { - console.error('Error al invalidar refresh token:', error); - // Continuar con el logout aunque falle - } - } - + const accessToken = localStorage.getItem('access_token'); + // Limpiar store de compañías try { const { companyStore } = await import('./stores/company.svelte'); @@ -388,30 +378,42 @@ export const logout = async () => { } catch (error) { console.error('Error al limpiar store de compañías:', error); } - + // Limpiar estado local authStore.reset(); localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); deleteCookie('access_token'); deleteCookie('refresh_token'); - + // Si hay instancia de Keycloak, hacer logout de Keycloak if (keycloakInstance?.authenticated) { + // Primero notificamos al servidor para limpieza de cookies (SvelteKit) + try { + await fetch('/logout', { + method: 'POST' + }); + } catch (e) { + console.error("Error calling server logout:", e); + } + await keycloakInstance.logout({ redirectUri: window.location.origin + '/login' }); return; } - + // Llamar al endpoint del servidor para limpiar cookies de SvelteKit // Usar un formulario para hacer POST y permitir la redirección const form = document.createElement('form'); form.method = 'POST'; form.action = '/logout'; + + + document.body.appendChild(form); form.submit(); - + } catch (error) { console.error('Error durante logout:', error); // Asegurar que se redirija al login aunque haya error @@ -435,11 +437,11 @@ export const getToken = (): string | null => { if (keycloakInstance?.token) { return keycloakInstance.token; } - + // Si no, intentar de localStorage if (browser) { let token = localStorage.getItem('access_token'); - + // Si no hay token en localStorage, intentar de las cookies if (!token) { token = getCookie('access_token'); @@ -448,10 +450,10 @@ export const getToken = (): string | null => { localStorage.setItem('access_token', token); } } - + return token; } - + return null; }; @@ -483,7 +485,7 @@ export const refreshAccessToken = async (): Promise => { authStore.setToken(newAccessToken); localStorage.setItem('access_token', newAccessToken); - + if (newRefreshToken) { localStorage.setItem('refresh_token', newRefreshToken); } diff --git a/frontend/src/lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte b/frontend/src/lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte index c2c1796d..47e19ee7 100644 --- a/frontend/src/lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte +++ b/frontend/src/lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte @@ -15,33 +15,26 @@ let checked = $state(false); async function checkExchangeRate() { - console.log('[ExchangeRateGuard] Checking...', companyStore.activeCompany); if (!companyStore.activeCompany?.id) { - console.log('[ExchangeRateGuard] No active company'); return; } // Use local date instead of UTC const today = new Date().toLocaleDateString('fr-CA'); // YYYY-MM-DD - console.log('[ExchangeRateGuard] Date:', today); try { const response = await getExchangeRates(companyStore.activeCompany.id, { date: today, page_size: 1 }); - console.log('[ExchangeRateGuard] Response (stringified):', JSON.stringify(response, null, 2)); // api.get returns { data: ..., status: ... } and types now reflect that const items = response.data?.items || []; if (items.length === 0) { - console.log('[ExchangeRateGuard] No rate found, attempting to open modal'); if (!uiStore.isExchangeRateDialogOpen) { uiStore.isExchangeRateDialogOpen = true; open = true; - } else { - console.log('[ExchangeRateGuard] Modal already open, skipping'); } } } catch (error) { @@ -58,7 +51,6 @@ }); function handleSuccess() { - console.log('Exchange rate created successfully via guard'); uiStore.isExchangeRateDialogOpen = false; checked = true; } @@ -70,8 +62,6 @@ } else if (checked) { // Solo limpiar si este componente ya hizo su check inicial // y el estado local es cerrado. - // Usamos una pequeña comprobación para no pisar a otros si fuera necesario, - // pero como son excluyentes, esto debería bastar. if (uiStore.isExchangeRateDialogOpen && !open) { uiStore.isExchangeRateDialogOpen = false; } diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index d422ff5f..ee0a8d9a 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -78,6 +78,13 @@ export function getSidebarData(): SidebarData { icon: LayoutDashboard, items: [], }, + { + title: m["sidebar.audit_logs"](), + url: "/dashboard/audit_logs", + icon: Shield, + items: [], + }, + { title: m["sidebar.reference_data.title"](), url: "/dashboard", diff --git a/frontend/src/lib/config/backend.ts b/frontend/src/lib/config/backend.ts new file mode 100644 index 00000000..2939a035 --- /dev/null +++ b/frontend/src/lib/config/backend.ts @@ -0,0 +1,51 @@ +/** + * Configuración de conexión al backend + * Detecta automáticamente el entorno y usa la URL correcta + */ + +export function getBackendUrl(): string { + // 1. Si existe variable de entorno, úsala (override manual) + if (import.meta.env.VITE_BACKEND_URL) { + return import.meta.env.VITE_BACKEND_URL; + } + + // 2. Detección automática basada en dónde corre el código + if (typeof window !== 'undefined') { + // CLIENTE (Browser): usar la URL pública del backend + // En local: http://localhost:8000 + // En prod: mismo dominio o dominio específico + const hostname = window.location.hostname; + + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return 'http://localhost:8000/api'; + } + + // En producción, asumir que el backend está en el mismo dominio /api + // o usar un subdominio específico + return `${window.location.protocol}//${hostname}/api`; + } else { + // SERVIDOR (SvelteKit SSR/Endpoints): usar URL interna + // En Docker: http://backend:8000 + // En local: http://127.0.0.1:8000 (IPv4 explícito) + + // Detectar si estamos en Docker por hostname + const isDocker = process.env.HOSTNAME?.includes('docker'); + + if (isDocker) { + return 'http://backend:8000/api'; + } + + // En desarrollo local, usar IPv4 explícito para evitar problemas con IPv6 + return 'http://127.0.0.1:8000/api'; + } +} + +export const BACKEND_URL = getBackendUrl(); + +// Helper para logs +export function logBackendConfig() { + console.log('[Backend Config]', { + url: BACKEND_URL, + isServer: typeof window === 'undefined', + }); +} diff --git a/frontend/src/routes/dashboard/audit_logs/+page.svelte b/frontend/src/routes/dashboard/audit_logs/+page.svelte new file mode 100644 index 00000000..8968b41b --- /dev/null +++ b/frontend/src/routes/dashboard/audit_logs/+page.svelte @@ -0,0 +1,346 @@ + + +
+
+
+

{m['sidebar.audit_logs_title']()}

+

{m['sidebar.audit_logs_description']()}

+
+
+ +
+
+ + +
+ + + Filtros + + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+
+
+ + + + +
+
+ Registros + Total: {total} registros encontrados +
+
+
+ + {#if error} +
+ Error al cargar datos: {error} +
+ {:else} +
+ + + + ID + Referencia + Procedimiento + Movimiento + Usuario + Fecha + Hora + + + + {#if loading && logs.length === 0} + + Cargando... + + {:else if logs.length === 0} + + No se encontraron registros + + {:else} + {#each logs as log} + + {log.spec_id} + {log.reference} + {log.procedure} + {log.movement} + {log.username} + {formatDate(log.date, log.timestamp)} + {formatTime(log.time, log.timestamp)} + + {/each} + {/if} + + + + +
+ {#if loading && logs.length > 0} +
+ + Cargando más registros... +
+ {/if} +
+
+ {/if} +
+
+
diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 38cfebc8..c8492b7a 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -8,11 +8,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => { clearAuthTokens(cookies); return {}; } - + // Limpiar siempre las cookies de sesión anterior al cargar login // Esto evita que se queden datos del tenant anterior clearAuthTokens(cookies); - + // Permitir acceso al login sin redirigir automáticamente // Esto evita bucles de redirección cuando el token existe pero puede estar expirado return {}; @@ -32,7 +32,7 @@ export const actions = { try { const baseUrl = getServerApiUrl(); const loginUrl = `${baseUrl}v1/auth/login`; - + const requestBody = { username, password, @@ -46,11 +46,11 @@ export const actions = { }, body: JSON.stringify(requestBody) }); - + const result = await response.json(); if (!response.ok) { - return fail(response.status, { + return fail(response.status, { error: result.detail || 'Error de autenticación', username, tenant_slug @@ -72,8 +72,8 @@ export const actions = { if (error && typeof error === 'object' && 'status' in error && 'location' in error) { throw error; } - - return fail(500, { + + return fail(500, { error: 'Error de conexión con el servidor: ' + (error instanceof Error ? error.message : String(error)), username, tenant_slug diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index c00e3e3b..679b0816 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -5,10 +5,10 @@ export const POST: RequestHandler = async ({ cookies }) => { // Eliminar todas las cookies de autenticación cookies.delete('access_token', { path: '/' }); cookies.delete('refresh_token', { path: '/' }); - + // Eliminar la cookie de la compañía activa cookies.delete('active_company_id', { path: '/' }); - + // Redirigir al login throw redirect(303, '/login'); };