From f90e20469a2be88de1056dabcc8eb927229a14cf Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 10 Feb 2026 14:07:20 -0600 Subject: [PATCH 1/5] feat: Implement a comprehensive audit log system with a dedicated dashboard page and backend API. --- .../api/v1/modules/a76/audit_log/events.py | 116 ++++++ .../v1/modules/a76/audit_log/middleware.py | 22 ++ .../api/v1/modules/a76/audit_log/models.py | 56 +++ .../api/v1/modules/a76/audit_log/router.py | 93 +++++ .../api/v1/modules/a76/audit_log/schemas.py | 52 +++ .../v1/modules/a76/audit_log/services/core.py | 148 ++++++++ .../modules/a76/audit_log/services/service.py | 162 +++++++++ backend/api/v1/modules/a76/router.py | 6 +- backend/core/context.py | 10 + backend/main.py | 77 +++- .../src/lib/api/dashboard/a76/audit_log.ts | 73 ++++ .../dashboard/a76/reports/reports-invoices.ts | 5 - .../src/lib/components/sidebar/modules.ts | 7 + .../routes/dashboard/bitacora/+page.svelte | 343 ++++++++++++++++++ 14 files changed, 1163 insertions(+), 7 deletions(-) create mode 100644 backend/api/v1/modules/a76/audit_log/events.py create mode 100644 backend/api/v1/modules/a76/audit_log/middleware.py create mode 100644 backend/api/v1/modules/a76/audit_log/models.py create mode 100644 backend/api/v1/modules/a76/audit_log/router.py create mode 100644 backend/api/v1/modules/a76/audit_log/schemas.py create mode 100644 backend/api/v1/modules/a76/audit_log/services/core.py create mode 100644 backend/api/v1/modules/a76/audit_log/services/service.py create mode 100644 backend/core/context.py create mode 100644 frontend/src/lib/api/dashboard/a76/audit_log.ts create mode 100644 frontend/src/routes/dashboard/bitacora/+page.svelte 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..45c1d1cc --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/services/core.py @@ -0,0 +1,148 @@ +""" +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", + "items": "CATALOGS", + } + + # 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", + } + + @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..57ad48f4 --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/services/service.py @@ -0,0 +1,162 @@ +""" +Audit Log Service +""" +from datetime import datetime +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 set to Mexico City as requested implicitly by legacy format example + tz = pytz.timezone('America/Mexico_City') + now = datetime.now(tz) + + 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 + + + # 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): + return AuditService.create_audit_log( + db=db, + reference="LOGIN", + procedure="SYSTEM SCAF", + movement="SYSTEM LOGIN", + username=username, + operation_type="LOGIN", + ip_address=ip_address + ) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 01c8c57d..714415b8 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -145,4 +145,8 @@ router.include_router( aviso_consolidado_export_router, prefix="/a76/reports/exportacion/aviso_consolidado", tags=["a76 / reports"] -) \ 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/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 b7ed6b69..95c9fe25 100644 --- a/backend/main.py +++ b/backend/main.py @@ -108,11 +108,86 @@ 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 + +# 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 + ]) + + # 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/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 7041c3a6..313e31a7 100644 --- a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts @@ -1,5 +1,4 @@ -const BASE_URL = import.meta.env.VITE_API_URL || ''; const BASE_URL = import.meta.env.VITE_API_URL || ''; export const invoicesReportsApi = { @@ -15,7 +14,6 @@ export const invoicesReportsApi = { const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { - method: 'POST', method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -25,14 +23,11 @@ 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) => { const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; - const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; - const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { method: 'GET', diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index a0dd99a8..61dea03e 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -77,6 +77,13 @@ export function getSidebarData(): SidebarData { icon: LayoutDashboard, items: [], }, + { + title: "Bitácora", + url: "/dashboard/bitacora", + icon: Shield, + items: [], + }, + { title: m["sidebar.reference_data.title"](), url: "/dashboard", diff --git a/frontend/src/routes/dashboard/bitacora/+page.svelte b/frontend/src/routes/dashboard/bitacora/+page.svelte new file mode 100644 index 00000000..ef465f05 --- /dev/null +++ b/frontend/src/routes/dashboard/bitacora/+page.svelte @@ -0,0 +1,343 @@ + + +
+
+
+

Bitácora de Movimientos

+

Auditoría detallada de operaciones del sistema

+
+
+ +
+
+ + +
+ + + 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)} + {formatTime(log.time)} + + {/each} + {/if} + + + + +
+ {#if loading && logs.length > 0} +
+ + Cargando más registros... +
+ {:else if !hasMore && logs.length > 0} + Fin de la bitácora + {/if} +
+
+ {/if} +
+
+
From 4b7a3a8f4a7f2b4eab716c64a6120cc0a6fc58d0 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 10 Feb 2026 14:21:00 -0600 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20implement=20new=20audit=20logs=20pa?= =?UTF-8?q?ge=20with=20filtering=20and=20infinite=20scroll,=20replacing=20?= =?UTF-8?q?the=20old=20'Bit=C3=A1cora'=20page=20and=20updating=20sidebar?= =?UTF-8?q?=20navigation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/messages/en.json | 3 +++ frontend/messages/es.json | 3 +++ frontend/src/lib/components/sidebar/modules.ts | 4 ++-- .../routes/dashboard/{bitacora => audit_logs}/+page.svelte | 5 +++-- 4 files changed, 11 insertions(+), 4 deletions(-) rename frontend/src/routes/dashboard/{bitacora => audit_logs}/+page.svelte (97%) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 49d51d59..434b325d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -91,6 +91,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 48dcc061..8501d1fb 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -91,6 +91,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/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 61dea03e..eaf925da 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -78,8 +78,8 @@ export function getSidebarData(): SidebarData { items: [], }, { - title: "Bitácora", - url: "/dashboard/bitacora", + title: m["sidebar.audit_logs"](), + url: "/dashboard/audit_logs", icon: Shield, items: [], }, diff --git a/frontend/src/routes/dashboard/bitacora/+page.svelte b/frontend/src/routes/dashboard/audit_logs/+page.svelte similarity index 97% rename from frontend/src/routes/dashboard/bitacora/+page.svelte rename to frontend/src/routes/dashboard/audit_logs/+page.svelte index ef465f05..31650018 100644 --- a/frontend/src/routes/dashboard/bitacora/+page.svelte +++ b/frontend/src/routes/dashboard/audit_logs/+page.svelte @@ -157,13 +157,14 @@ observer.disconnect(); }; }); + import * as m from '$lib/paraglide/messages.js';
-

Bitácora de Movimientos

-

Auditoría detallada de operaciones del sistema

+

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

+

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

- {:else if !hasMore && logs.length > 0} - Fin de la bitácora {/if}
diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index c00e3e3b..9eea7f0f 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -1,14 +1,86 @@ import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; +import { getServerApiUrl, getAuthTokens } from '$lib/server/api'; + +export const POST: RequestHandler = async ({ cookies, fetch }) => { + // Obtener tokens antes de borrarlos + const { accessToken, refreshToken } = getAuthTokens(cookies); + + if (refreshToken) { + try { + // Intentar obtener el username del access token + let username = "Unknown"; + if (accessToken) { + try { + const parts = accessToken.split('.'); + if (parts.length === 3) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + username = payload.preferred_username || payload.username || payload.sub || "Unknown"; + } + } catch (e) { + console.error("Error decoding token for logout audit:", e); + } + } + + // Llamar al backend para logout y auditoría + const baseUrl = getServerApiUrl(); + let finalUrl = `${baseUrl}v1/auth/logout`; + + // Logic to handle potential connectivity issues (Docker vs Localhost) + // If we are on the host but API_URL targets 'backend' container, it might fail. + const makeRequest = async (url: string) => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + refresh_token: refreshToken, + username: username + }) + }); + console.log(`[LOGOUT DEBUG] Backend response status: ${response.status} for url: ${url}`); + return response; + }; + + try { + await makeRequest(finalUrl); + console.log(`Logout processed for user ${username} at ${finalUrl}`); + } catch (err) { + console.warn(`Failed to contact backend at ${finalUrl}, retrying with localhost...`); + // Fallback: try localhost if the 'backend' hostname failed + if (finalUrl.includes('backend')) { + finalUrl = finalUrl.replace('backend', 'localhost'); + try { + await makeRequest(finalUrl); + console.log(`Logout processed for user ${username} at ${finalUrl} (fallback: localhost)`); + } catch (fallbackErr) { + console.warn(`Localhost failed, trying 127.0.0.1...`); + // Second Fallback: try 127.0.0.1 explicitly to avoid IPv6 issues + finalUrl = finalUrl.replace('localhost', '127.0.0.1'); + try { + await makeRequest(finalUrl); + console.log(`Logout processed for user ${username} at ${finalUrl} (fallback: 127.0.0.1)`); + } catch (secondFallbackErr) { + console.error("Logout audit failed even with 127.0.0.1 fallback:", secondFallbackErr); + } + } + } else { + console.error("Logout audit failed:", err); + } + } + } catch (error) { + console.error("Error reporting logout to backend:", error); + } + } -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'); }; From dfd9c06b06d927a37de8592bcdebf70d802d2e39 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 11 Feb 2026 11:13:32 -0600 Subject: [PATCH 4/5] feat: Standardize audit log timestamps to UTC in the backend and display local time in the frontend. --- .../v1/modules/a76/audit_log/services/service.py | 11 +++++------ .../src/routes/dashboard/audit_logs/+page.svelte | 14 ++++++++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/backend/api/v1/modules/a76/audit_log/services/service.py b/backend/api/v1/modules/a76/audit_log/services/service.py index 8da524fa..b8d3099a 100644 --- a/backend/api/v1/modules/a76/audit_log/services/service.py +++ b/backend/api/v1/modules/a76/audit_log/services/service.py @@ -40,9 +40,9 @@ class AuditService: """ Low-level creation of an Audit Log entry """ - # Timezone handling set to Hermosillo (Sonora) to match user preference (-1h vs CDMX) - tz = pytz.timezone('America/Hermosillo') - now = datetime.now(tz) + # 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, @@ -157,9 +157,8 @@ class AuditService: # Prevent duplicate login logs (debounce 5 seconds) # This handles cases where frontend might submit twice or redirects trigger re-auth try: - # Timezone handling set to Hermosillo (Sonora) to match user preference (-1h vs CDMX) - tz = pytz.timezone('America/Hermosillo') - now = datetime.now(tz) + # Timezone handling: Use UTC for consistency + now = datetime.now(pytz.UTC) five_seconds_ago = now - datetime.timedelta(seconds=5) # Check for recent login from same user diff --git a/frontend/src/routes/dashboard/audit_logs/+page.svelte b/frontend/src/routes/dashboard/audit_logs/+page.svelte index 707f8563..8968b41b 100644 --- a/frontend/src/routes/dashboard/audit_logs/+page.svelte +++ b/frontend/src/routes/dashboard/audit_logs/+page.svelte @@ -114,13 +114,19 @@ handleFilterChange(); } - function formatDate(dateStr: string): string { + function formatDate(dateStr: string, timestamp?: string): string { + if (timestamp) { + return new Date(timestamp).toLocaleDateString(); + } if (!dateStr) return ''; const [y, m, d] = dateStr.split('-'); return `${d}/${m}/${y}`; } - function formatTime(timeStr: string): string { + function formatTime(timeStr: string, timestamp?: string): string { + if (timestamp) { + return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } if (!timeStr) return ''; try { const [h, m] = timeStr.split(':'); @@ -316,8 +322,8 @@ {log.procedure} {log.movement} {log.username} - {formatDate(log.date)} - {formatTime(log.time)} + {formatDate(log.date, log.timestamp)} + {formatTime(log.time, log.timestamp)} {/each} {/if} From 85ca22e57452fd4fdeb41e15702d178f9c47bdcc Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 11 Feb 2026 16:22:03 -0600 Subject: [PATCH 5/5] feat: Implement dynamic backend URL configuration and streamline logout process by centralizing audit logging. --- .../modules/a76/audit_log/services/service.py | 14 +-- backend/api/v1/modules/core/auth/routes.py | 25 ++---- backend/api/v1/modules/core/auth/service.py | 7 +- backend/main.py | 1 - frontend/src/lib/auth.ts | 17 +++- .../exchange_rate/exchange-rate-guard.svelte | 89 ++++++++----------- frontend/src/lib/config/backend.ts | 51 +++++++++++ frontend/src/routes/login/+page.server.ts | 14 +-- frontend/src/routes/logout/+server.ts | 74 +-------------- 9 files changed, 130 insertions(+), 162 deletions(-) create mode 100644 frontend/src/lib/config/backend.ts diff --git a/backend/api/v1/modules/a76/audit_log/services/service.py b/backend/api/v1/modules/a76/audit_log/services/service.py index b8d3099a..9fdf4a6b 100644 --- a/backend/api/v1/modules/a76/audit_log/services/service.py +++ b/backend/api/v1/modules/a76/audit_log/services/service.py @@ -1,7 +1,7 @@ """ Audit Log Service """ -from datetime import datetime +from datetime import datetime, timedelta import pytz from typing import Optional, List, Dict, Any from sqlalchemy.orm import Session @@ -154,12 +154,14 @@ class AuditService: @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 - datetime.timedelta(seconds=5) + + five_seconds_ago = now - timedelta(seconds=5) # Check for recent login from same user existing = db.query(AuditLog).filter( @@ -170,12 +172,12 @@ class AuditService: ).first() if existing: - print(f"[AUDIT DEBUG] Duplicate login skipped for {username} within 5s") return existing except Exception as e: - print(f"[AUDIT WARNING] Failed to check duplicate login: {e}") - + import traceback + traceback.print_exc() + return AuditService.create_audit_log( db=db, reference="LOGIN", @@ -206,4 +208,4 @@ class AuditService: ) except Exception as e: # No re-lanzamos la excepción para no interrumpir el flujo de logout - print(f"Error logging logout: {e}") + pass diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index 26c8d5ee..949855b8 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -64,6 +64,8 @@ async def login( - tenant_slug: Slug del tenant al que pertenece """ service = AuthService(db) + import logging + logger = logging.getLogger(__name__) return service.login( login_data=login_data, ip_address=request.client.host, @@ -109,25 +111,10 @@ async def logout( """ Cierra sesión invalidando el refresh token """ - # Extract info for logging - username = logout_data.username or "Unknown" - ip_address = request.client.host - user_agent = request.headers.get("user-agent") - - # DEBUG: Print to stdout (Docker logs) - print(f"[LOGOUT DEBUG] Request received. Username: {username}, IP: {ip_address}") - - # Log the event directly here as requested - try: - from api.v1.modules.a76.audit_log.services.service import AuditService - AuditService.log_logout( - db=db, - username=username, - ip_address=ip_address, - user_agent=user_agent - ) - except Exception as e: - print(f"Error auditing logout: {e}") + # 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 297c6b19..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 @@ -142,17 +143,17 @@ 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, diff --git a/backend/main.py b/backend/main.py index 6fb0827d..3461a695 100644 --- a/backend/main.py +++ b/backend/main.py @@ -35,7 +35,6 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) -logger = logging.getLogger(__name__) # Crear aplicación FastAPI app = FastAPI( diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 339918f7..e75c29ff 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -367,10 +367,9 @@ 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'); - - + const accessToken = localStorage.getItem('access_token'); // Limpiar store de compañías try { @@ -389,6 +388,15 @@ export const logout = async () => { // 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' }); @@ -400,6 +408,9 @@ export const logout = async () => { const form = document.createElement('form'); form.method = 'POST'; form.action = '/logout'; + + + document.body.appendChild(form); form.submit(); 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 8f113fc1..b3d22504 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 @@ -1,59 +1,48 @@ - + 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/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 9eea7f0f..679b0816 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -1,79 +1,7 @@ import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getServerApiUrl, getAuthTokens } from '$lib/server/api'; - -export const POST: RequestHandler = async ({ cookies, fetch }) => { - // Obtener tokens antes de borrarlos - const { accessToken, refreshToken } = getAuthTokens(cookies); - - if (refreshToken) { - try { - // Intentar obtener el username del access token - let username = "Unknown"; - if (accessToken) { - try { - const parts = accessToken.split('.'); - if (parts.length === 3) { - const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); - username = payload.preferred_username || payload.username || payload.sub || "Unknown"; - } - } catch (e) { - console.error("Error decoding token for logout audit:", e); - } - } - - // Llamar al backend para logout y auditoría - const baseUrl = getServerApiUrl(); - let finalUrl = `${baseUrl}v1/auth/logout`; - - // Logic to handle potential connectivity issues (Docker vs Localhost) - // If we are on the host but API_URL targets 'backend' container, it might fail. - const makeRequest = async (url: string) => { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - refresh_token: refreshToken, - username: username - }) - }); - console.log(`[LOGOUT DEBUG] Backend response status: ${response.status} for url: ${url}`); - return response; - }; - - try { - await makeRequest(finalUrl); - console.log(`Logout processed for user ${username} at ${finalUrl}`); - } catch (err) { - console.warn(`Failed to contact backend at ${finalUrl}, retrying with localhost...`); - // Fallback: try localhost if the 'backend' hostname failed - if (finalUrl.includes('backend')) { - finalUrl = finalUrl.replace('backend', 'localhost'); - try { - await makeRequest(finalUrl); - console.log(`Logout processed for user ${username} at ${finalUrl} (fallback: localhost)`); - } catch (fallbackErr) { - console.warn(`Localhost failed, trying 127.0.0.1...`); - // Second Fallback: try 127.0.0.1 explicitly to avoid IPv6 issues - finalUrl = finalUrl.replace('localhost', '127.0.0.1'); - try { - await makeRequest(finalUrl); - console.log(`Logout processed for user ${username} at ${finalUrl} (fallback: 127.0.0.1)`); - } catch (secondFallbackErr) { - console.error("Logout audit failed even with 127.0.0.1 fallback:", secondFallbackErr); - } - } - } else { - console.error("Logout audit failed:", err); - } - } - } catch (error) { - console.error("Error reporting logout to backend:", error); - } - } +export const POST: RequestHandler = async ({ cookies }) => { // Eliminar todas las cookies de autenticación cookies.delete('access_token', { path: '/' }); cookies.delete('refresh_token', { path: '/' });