diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index 5ead8cc0..5ef2ec88 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -50,27 +50,14 @@ from api.v1.modules.public.reference_data.transport_modes.seed import ( from api.v1.modules.public.reference_data.transport_types.seed import ( seed as transport_types_seed, ) -from api.v1.modules.public.reference_data.trailer_types.seed import ( - seed as trailer_types_seed, -) from api.v1.modules.public.reference_data.valuation_methods.seed import ( seed as valuation_methods_seed, ) -from api.v1.modules.a76.general_catalogs.units_of_measure.seed import ( - seed as units_of_measure_seed, -) -from api.v1.modules.a76.general_catalogs.units_of_measure.seed_ace import ( - seed as ace_seed, -) -from api.v1.modules.a76.general_catalogs.units_of_measure.seed_oma import ( - seed as oma_seed, -) -from api.v1.modules.a76.general_catalogs.units_of_measure.seed_ame import ( - seed as ame_seed, -) -from api.v1.modules.a76.general_catalogs.units_of_measure.seed_adua import ( - seed as adua_seed, -) +from api.v1.modules.a76.general_catalogs.units_of_measure.seed import seed as units_of_measure_seed +from api.v1.modules.a76.general_catalogs.units_of_measure.seed_ace import seed as ace_seed +from api.v1.modules.a76.general_catalogs.units_of_measure.seed_oma import seed as oma_seed +from api.v1.modules.a76.general_catalogs.units_of_measure.seed_ame import seed as ame_seed +from api.v1.modules.a76.general_catalogs.units_of_measure.seed_adua import seed as adua_seed from api.v1.modules.core.permissions.seed import ( seed_invoices, seed_user, @@ -86,12 +73,12 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - """Upgrade schema.""" + """Upgrade schema.""" # --- UTILIDAD DE FORMATEO --- def format_value(val): - if val is None or str(val).strip() == "" or str(val).upper() == "NONE": - return "NULL" + if val is None or str(val).strip() == '' or str(val).upper() == 'NONE': + return 'NULL' return f"'{str(val).replace(chr(39), chr(39)*2)}'" # --- SEEDS PUBLIC (Tablas base) --- @@ -306,20 +293,6 @@ def upgrade() -> None: """ ) - values_trailert = ", ".join( - [ - f"('{code}', '{desc.replace(chr(39), chr(39)*2)}')" - for code, desc in trailer_types_seed - ] - ) - op.execute( - f""" - INSERT INTO public.trailer_type (trailer_type_key, description) VALUES - {values_trailert} - ON CONFLICT (trailer_type_key) DO NOTHING; - """ - ) - values_vm = ", ".join( [ f"('{key}', '{desc.replace(chr(39), chr(39)*2)}')" @@ -337,131 +310,93 @@ def upgrade() -> None: # --- SEEDS A76 (Unidades de Medida) --- # ACE - val_ace = ", ".join( - [f"({format_value(c)}, {format_value(d)})" for c, d in ace_seed] - ) - op.execute( - f"INSERT INTO a76.unit_of_measure_ace (code, description) VALUES {val_ace} ON CONFLICT ON CONSTRAINT uq_uom_ace_code DO NOTHING;" - ) + val_ace = ", ".join([f"({format_value(c)}, {format_value(d)})" for c, d in ace_seed]) + op.execute(f"INSERT INTO a76.unit_of_measure_ace (code, description) VALUES {val_ace} ON CONFLICT ON CONSTRAINT uq_uom_ace_code DO NOTHING;") # OMA - val_oma = ", ".join( - [f"({format_value(c)}, {format_value(d)})" for c, d in oma_seed] - ) - op.execute( - f"INSERT INTO a76.unit_of_measure_oma (code, description) VALUES {val_oma} ON CONFLICT ON CONSTRAINT uq_uom_oma_code DO NOTHING;" - ) + val_oma = ", ".join([f"({format_value(c)}, {format_value(d)})" for c, d in oma_seed]) + op.execute(f"INSERT INTO a76.unit_of_measure_oma (code, description) VALUES {val_oma} ON CONFLICT ON CONSTRAINT uq_uom_oma_code DO NOTHING;") # AME - val_ame = ", ".join( - [f"({format_value(c)}, {format_value(d)})" for c, d in ame_seed] - ) - op.execute( - f"INSERT INTO a76.unit_of_measure_american (code, description) VALUES {val_ame} ON CONFLICT ON CONSTRAINT uq_uom_american_code DO NOTHING;" - ) + val_ame = ", ".join([f"({format_value(c)}, {format_value(d)})" for c, d in ame_seed]) + op.execute(f"INSERT INTO a76.unit_of_measure_american (code, description) VALUES {val_ame} ON CONFLICT ON CONSTRAINT uq_uom_american_code DO NOTHING;") # ADUA (Customs) - val_adua = ", ".join( - [f"({format_value(c)}, {format_value(d)})" for c, d in adua_seed] - ) - op.execute( - f"INSERT INTO a76.unit_of_measure_customs (code, description) VALUES {val_adua} ON CONFLICT ON CONSTRAINT uq_uom_customs_code DO NOTHING;" - ) + val_adua = ", ".join([f"({format_value(c)}, {format_value(d)})" for c, d in adua_seed]) + op.execute(f"INSERT INTO a76.unit_of_measure_customs (code, description) VALUES {val_adua} ON CONFLICT ON CONSTRAINT uq_uom_customs_code DO NOTHING;") # Recolectar códigos adicionales que faltan en los catálogos additional_customs = set() additional_american = set() additional_ace = set() additional_oma = set() - + existing_customs = {c for c, d in adua_seed} existing_american = {c for c, d in ame_seed} existing_ace = {c for c, d in ace_seed} existing_oma = {c for c, d in oma_seed} - + for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed: if customs and customs.strip() and customs not in existing_customs: - additional_customs.add((customs, f"Auto-generated from {code}")) + additional_customs.add((customs, f'Auto-generated from {code}')) if american and american.strip() and american not in existing_american: - additional_american.add((american, f"Auto-generated from {code}")) + additional_american.add((american, f'Auto-generated from {code}')) if ace and ace.strip() and ace not in existing_ace: - additional_ace.add((ace, f"Auto-generated from {code}")) + additional_ace.add((ace, f'Auto-generated from {code}')) if oma and oma.strip() and oma not in existing_oma: - additional_oma.add((oma, f"Auto-generated from {code}")) - + additional_oma.add((oma, f'Auto-generated from {code}')) + # Insertar códigos adicionales if additional_customs: - val_add_customs = ", ".join( - [f"({format_value(c)}, {format_value(d)})" for c, d in additional_customs] - ) - op.execute( - f"INSERT INTO a76.unit_of_measure_customs (code, description, a76_unit_code) VALUES {val_add_customs} ON CONFLICT ON CONSTRAINT uq_uom_customs_code DO NOTHING;" - ) - + val_add_customs = ", ".join([f"({format_value(c)}, {format_value(d)})" for c, d in additional_customs]) + op.execute(f"INSERT INTO a76.unit_of_measure_customs (code, description, a76_unit_code) VALUES {val_add_customs} ON CONFLICT ON CONSTRAINT uq_uom_customs_code DO NOTHING;") + if additional_american: - val_add_american = ", ".join( - [f"({format_value(c)}, {format_value(d)})" for c, d in additional_american] - ) - op.execute( - f"INSERT INTO a76.unit_of_measure_american (code, description) VALUES {val_add_american} ON CONFLICT ON CONSTRAINT uq_uom_american_code DO NOTHING;" - ) - + val_add_american = ", ".join([f"({format_value(c)}, {format_value(d)})" for c, d in additional_american]) + op.execute(f"INSERT INTO a76.unit_of_measure_american (code, description) VALUES {val_add_american} ON CONFLICT ON CONSTRAINT uq_uom_american_code DO NOTHING;") + if additional_ace: - val_add_ace = ", ".join( - [f"({format_value(c)}, {format_value(d)})" for c, d in additional_ace] - ) - op.execute( - f"INSERT INTO a76.unit_of_measure_ace (code, description) VALUES {val_add_ace} ON CONFLICT ON CONSTRAINT uq_uom_ace_code DO NOTHING;" - ) - + val_add_ace = ", ".join([f"({format_value(c)}, {format_value(d)})" for c, d in additional_ace]) + op.execute(f"INSERT INTO a76.unit_of_measure_ace (code, description) VALUES {val_add_ace} ON CONFLICT ON CONSTRAINT uq_uom_ace_code DO NOTHING;") + if additional_oma: - val_add_oma = ", ".join( - [f"({format_value(c)}, {format_value(d)})" for c, d in additional_oma] - ) - op.execute( - f"INSERT INTO a76.unit_of_measure_oma (code, description) VALUES {val_add_oma} ON CONFLICT ON CONSTRAINT uq_uom_oma_code DO NOTHING;" - ) + val_add_oma = ", ".join([f"({format_value(c)}, {format_value(d)})" for c, d in additional_oma]) + op.execute(f"INSERT INTO a76.unit_of_measure_oma (code, description) VALUES {val_add_oma} ON CONFLICT ON CONSTRAINT uq_uom_oma_code DO NOTHING;") # TABLA MAESTRA UOM # TODO: Generar tenant_id y company_id correctos - val_uom = ", ".join( - [ - f"({format_value(code)}, {format_value(desc)}, {format_value(desc_en)}, " - f"{format_value(customs)}, {format_value(american)}, {format_value(ace)}, {format_value(oma)}, 1, 1)" - for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed - ] - ) + val_uom = ", ".join([ + f"({format_value(code)}, {format_value(desc)}, {format_value(desc_en)}, " + f"{format_value(customs)}, {format_value(american)}, {format_value(ace)}, {format_value(oma)}, 1, 1)" + for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed + ]) op.execute("ALTER TABLE a76.units_of_measure DISABLE TRIGGER ALL;") - op.execute( - f""" + op.execute(f""" INSERT INTO a76.units_of_measure (code, description, description_en, customs_code, american_code, ace_code, oma_code, tenant_id, company_id) VALUES {val_uom} ON CONFLICT (code, tenant_id, company_id) DO NOTHING; - """ - ) + """) op.execute("ALTER TABLE a76.units_of_measure ENABLE TRIGGER ALL;") # --- SEEDS CORE (Permissions) --- - + # Combinar todas las seeds de permisos all_permissions = seed_invoices + seed_user + seed_report + seed_roles - - values_permissions = ", ".join( - [ - f"({format_value(code)}, {format_value(description)}, {format_value(module)}, {format_value(action)})" - for code, description, module, action in all_permissions - ] - ) - + + values_permissions = ", ".join([ + f"({format_value(code)}, {format_value(desc)}, {format_value(resource)}, {format_value(action)})" + for code, desc, resource, action in all_permissions + ]) + if values_permissions: - op.execute( - f""" - INSERT INTO core.permissions (code, description, module, action) + op.execute(f""" + INSERT INTO core.permissions (code, description, resource, action) VALUES {values_permissions} ON CONFLICT (code) DO NOTHING; - """ - ) + """) + + def downgrade() -> None: @@ -492,4 +427,4 @@ def downgrade() -> None: op.drop_table("unit_of_measure_customs", schema="a76") op.drop_table("unit_of_measure_american", schema="a76") op.drop_table("unit_of_measure_oma", schema="a76") - op.drop_table("unit_of_measure_ace", schema="a76") + op.drop_table("unit_of_measure_ace", schema="a76") \ No newline at end of file diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py index 26dfa830..78e213d8 100644 --- a/backend/api/v1/common/tenant_crud_routes.py +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -15,8 +15,7 @@ ServiceType = TypeVar("ServiceType") class TenantCRUDRoutes( - Generic[CreateSchemaType, UpdateSchemaType, - ResponseSchemaType, ServiceType] + Generic[CreateSchemaType, UpdateSchemaType, ResponseSchemaType, ServiceType] ): """ Generic CRUD routes factory for tenant-scoped resources @@ -88,6 +87,13 @@ class TenantCRUDRoutes( enable_filters: bool = False, # Enable custom filters in list endpoint default_page_size: int = 50, max_page_size: int = 100, + # Permissions for each operation + list_permissions: Optional[list[str]] = None, + get_permissions: Optional[list[str]] = None, + create_permissions: Optional[list[str]] = None, + update_permissions: Optional[list[str]] = None, + delete_permissions: Optional[list[str]] = None, + require_all: bool = True, # If True, requires ALL permissions; if False, requires ANY ): self.service = service self.create_schema = create_schema @@ -104,6 +110,12 @@ class TenantCRUDRoutes( self.enable_filters = enable_filters self.default_page_size = default_page_size self.max_page_size = max_page_size + self.list_permissions = list_permissions + self.get_permissions = get_permissions + self.create_permissions = create_permissions + self.update_permissions = update_permissions + self.delete_permissions = delete_permissions + self.require_all = require_all self.router = APIRouter(prefix=prefix, tags=tags) self._register_routes() @@ -130,18 +142,22 @@ class TenantCRUDRoutes( le=self.max_page_size, description="Page size", ), - status: Optional[str] = Query( - None, description="Filter by status"), + status: Optional[str] = Query(None, description="Filter by status"), operation_type: Optional[str] = Query( - None, description="Filter by operation type"), + None, description="Filter by operation type" + ), invoice_type: Optional[str] = Query( - None, description="Filter by invoice type"), + None, description="Filter by invoice type" + ), db: Session = Depends(self.db_dependency), - current_user: Dict[str, Any] = Depends( - self.auth_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user + db, + company_id, + current_user, + self.list_permissions, + self.require_all, ) skip = (page - 1) * page_size @@ -184,11 +200,14 @@ class TenantCRUDRoutes( description="Page size", ), db: Session = Depends(self.db_dependency), - current_user: Dict[str, Any] = Depends( - self.auth_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user + db, + company_id, + current_user, + self.list_permissions, + self.require_all, ) skip = (page - 1) * page_size @@ -225,7 +244,8 @@ class TenantCRUDRoutes( ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, company_id, current_user, self.get_permissions, self.require_all + ) parent_id = path_params.get(self.parent_id_name) # Try method with 4 params (pedimento_id, tenant_id, company_id) @@ -239,8 +259,7 @@ class TenantCRUDRoutes( db, parent_id, tenant_id, company_id ) else: - resource = self.service.get( - db, parent_id, tenant_id, company_id) + resource = self.service.get(db, parent_id, tenant_id, company_id) if not resource: raise HTTPException( @@ -265,7 +284,8 @@ class TenantCRUDRoutes( current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, company_id, current_user, self.get_permissions, self.require_all + ) resource = self.service.get_by_id( db, resource_id, tenant_id, company_id @@ -298,7 +318,12 @@ class TenantCRUDRoutes( current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.create_permissions, + self.require_all, + ) # For child resources, parent_id validation would go here try: @@ -310,6 +335,7 @@ class TenantCRUDRoutes( except Exception as e: # Re-lanzar otros errores raise + else: # Parent resource - no parent_id needed @@ -330,7 +356,12 @@ class TenantCRUDRoutes( current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.create_permissions, + self.require_all, + ) try: resource = self.service.create(db, data, tenant_id, company_id) return resource @@ -364,7 +395,12 @@ class TenantCRUDRoutes( **path_params, ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.update_permissions, + self.require_all, + ) parent_id = path_params.get(self.parent_id_name) try: @@ -404,7 +440,12 @@ class TenantCRUDRoutes( ): f"""Update {self.resource_name}""" tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.update_permissions, + self.require_all, + ) try: resource = self.service.update( @@ -438,11 +479,15 @@ class TenantCRUDRoutes( **path_params, ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.delete_permissions, + self.require_all, + ) parent_id = path_params.get(self.parent_id_name) - success = self.service.delete( - db, parent_id, tenant_id, company_id) + success = self.service.delete(db, parent_id, tenant_id, company_id) if not success: raise HTTPException( @@ -467,10 +512,14 @@ class TenantCRUDRoutes( current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.delete_permissions, + self.require_all, + ) - success = self.service.delete( - db, resource_id, tenant_id, company_id) + success = self.service.delete(db, resource_id, tenant_id, company_id) if not success: raise HTTPException( diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py index e81706a1..ceb430d9 100644 --- a/backend/api/v1/modules/a76/invoices/routes.py +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -20,9 +20,14 @@ invoice_crud = TenantCRUDRoutes( tags=[], resource_name="Invoice", id_name="invoice_id", - id_type=int, + id_type=int, enable_list=True, # Enable list endpoint with pagination enable_filters=True, # Enable filters for status, operation_type, etc. + list_permissions=[], + get_permissions=[], + create_permissions=[], + update_permissions=[], + delete_permissions=[], default_page_size=50, max_page_size=200, ) diff --git a/backend/api/v1/modules/core/permissions/dependencies.py b/backend/api/v1/modules/core/permissions/dependencies.py index 95354071..83eea516 100644 --- a/backend/api/v1/modules/core/permissions/dependencies.py +++ b/backend/api/v1/modules/core/permissions/dependencies.py @@ -11,34 +11,6 @@ from core.database import get_core_db from core.security import get_current_user # Asumiendo que existe esta función from .service import PermissionService - -# Dependencia para obtener el ID del cliente del header o contexto -async def get_client_id( - x_client_id: Optional[str] = Header(None, alias="X-Client-ID") -) -> int: - """ - Obtiene el ID del cliente desde el header de la petición. - - En producción, esto podría obtenerse de: - - Un header HTTP (X-Client-ID) - - Un subdomain (cliente1.miapp.com) - - El token JWT del usuario - - La sesión del usuario - """ - if not x_client_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Client ID is required. Provide X-Client-ID header.", - ) - - try: - return int(x_client_id) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid client ID format" - ) - - # Dependencia para obtener el servicio de permisos def get_permission_service(db: Session = Depends(get_core_db)) -> PermissionService: """ @@ -72,9 +44,9 @@ class PermissionChecker: async def __call__( self, - current_user: dict = Depends(get_current_user), - client_id: int = Depends(get_client_id), - permission_service: PermissionService = Depends(get_permission_service), + client_id: int, + current_user: dict = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), ): """ Verifica que el usuario tenga los permisos requeridos. @@ -130,8 +102,8 @@ class RequirePermission: async def __call__( self, - current_user: dict = Depends(get_current_user), - client_id: int = Depends(get_client_id), + client_id: int, + current_user: dict = Depends(get_current_user), permission_service: PermissionService = Depends(get_permission_service), ): user_id = current_user.get("sub") or current_user.get("id") @@ -212,8 +184,8 @@ def require_permissions(*permissions: str, require_all: bool = True): # Función helper para obtener permisos del usuario actual async def get_current_user_permissions( - current_user: dict = Depends(get_current_user), - client_id: int = Depends(get_client_id), + client_id: int, + current_user: dict = Depends(get_current_user), permission_service: PermissionService = Depends(get_permission_service), ) -> set: """ diff --git a/backend/core/security.py b/backend/core/security.py index 9bb89b8b..1c7f68c1 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -176,29 +176,64 @@ def validate_company_access( def validate_access_to_resource( - db: Session, company_id: int, current_user: Dict[str, Any] + db: Session, + company_id: int, + current_user: Dict[str, Any], + required_permissions: Optional[list[str]] = None, + require_all: bool = False, ) -> int: """ Valida que el usuario tenga acceso a un recurso específico basado en company_id - y regresa el tenant_id + y regresa el tenant_id. Opcionalmente verifica permisos. Args: + db: Sesión de base de datos company_id: company_id asociado al recurso current_user: Información del usuario actual desde el token + required_permissions: Lista opcional de permisos requeridos. Si es None, no verifica permisos. + require_all: Si True, requiere TODOS los permisos. Si False, requiere AL MENOS UNO. Returns: tenant_id si el usuario tiene acceso Raises: - HTTPException: Si no hay tenant_id o no tiene acceso + HTTPException: Si no hay tenant_id, no tiene acceso o no tiene los permisos requeridos """ - tenant_id = get_tenant_from_token(current_user) + tenant_id = current_user.get("tenant_id") if not tenant_id: raise HTTPException(status_code=400, detail="Tenant ID not found in token") if not validate_company_access(db, company_id, current_user): raise HTTPException(status_code=403, detail="Access denied to this company") - # Validar que el tenant_id del usuario coincida con el del recurso + # Verificar permisos si se proporcionaron + if required_permissions: + from api.v1.modules.core.permissions.service import PermissionService + + user_id = current_user.get("sub") or current_user.get("id") + if not user_id: + raise HTTPException(status_code=401, detail="User ID not found in token") + + permission_service = PermissionService(db) + + if require_all: + has_access = permission_service.has_all_permissions( + user_id=user_id, + company_id=company_id, + permission_codes=required_permissions, + ) + else: + has_access = permission_service.has_any_permission( + user_id=user_id, + company_id=company_id, + permission_codes=required_permissions, + ) + + if not has_access: + raise HTTPException( + status_code=403, + detail=f"Missing required permissions: {', '.join(required_permissions)}", + ) + return tenant_id diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5d863da1..0f0aeaa4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -3,6 +3,7 @@ */ import { getToken } from './auth'; import { browser } from '$app/environment'; +import { toast } from 'svelte-sonner'; // Normalize API_BASE_URL to remove trailing slash const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); @@ -172,6 +173,23 @@ 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) { + // Si es 403 (Forbidden), mostrar toast de permisos insuficientes + if (response.status === 403) { + if (browser) { + toast.error('No tienes permisos para realizar esta acción', { + duration: 4000, + description: 'Contacta a tu administrador si crees que esto es un error' + }); + } + // Retornar el error 403 sin intentar refresh + const data = await response.json(); + return { + error: data.detail || 'No tienes permisos para realizar esta acción', + status: 403 + }; + } + + // Si es 401, intentar refrescar el token isRefreshing = true; try { diff --git a/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte index 23fed8a6..c08d1e4f 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte @@ -27,7 +27,7 @@ // Campo de comentario estatus comments_status: invoice.comments_status || '', // Campos que van en diferentes recursos pero se editan aquí - transport_mode: invoice.logistics?.[0]?.transport_mode || null, + transport_mode: invoice.logistics?.transport_mode || null, is_mixed: invoice.compliance_mx?.is_mixed || null, print_stamp: invoice.financials?.seal_value_2500 || false, rule_3121_parties_ii: false, diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index ccbfdfdb..3b2c6acf 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -133,7 +133,7 @@ export async function refreshAccessToken( * @param cookies - Objeto de cookies de SvelteKit * @param fetch - Función fetch de SvelteKit * @param redirectUrl - URL a la que redirigir si falla la autenticación (opcional) - * @param timeout - Timeout en milisegundos (default: 10000ms) + * @param timeout - Timeout en milisegundos (default: 30000ms) */ export async function authenticatedFetch( endpoint: string, @@ -141,7 +141,7 @@ export async function authenticatedFetch( cookies: Cookies, fetch: typeof globalThis.fetch, redirectUrl?: string, - timeout: number = 10000 + timeout: number = 30000 ): Promise { try { const baseUrl = getServerApiUrl(); @@ -180,6 +180,12 @@ export async function authenticatedFetch( clearTimeout(timeoutId); + // Si es 403, no intentar refrescar - es un problema de permisos + if (response.status === 403) { + console.warn('🚫 [API] Acceso denegado (403):', endpoint); + return response; // Retornar directamente para que el llamador maneje el error + } + // Si es 401, intentar refrescar el token if (response.status === 401) { const newToken = await refreshAccessToken(cookies, fetch); @@ -346,3 +352,32 @@ export async function getActiveCompanyId( return companyId || null; } + +/** + * Helper para manejar respuestas de API y convertir errores 403 en formato adecuado + * para mostrar toasts en el cliente + */ +export async function handleApiResponse( + response: Response +): Promise<{ data?: T; error?: { detail: string; status: number; isForbidden?: boolean } }> { + if (response.ok) { + // Para respuestas sin contenido (204) + if (response.status === 204) { + return { data: null as T }; + } + + const data = await response.json(); + return { data }; + } + + // Manejar errores + const errorData = await response.json().catch(() => ({ detail: 'Error desconocido' })); + + const error = { + detail: errorData.detail || errorData.message || 'Error en la petición', + status: response.status, + isForbidden: response.status === 403 + }; + + return { error }; +} diff --git a/frontend/src/lib/utils/error-handler.ts b/frontend/src/lib/utils/error-handler.ts new file mode 100644 index 00000000..9f3db133 --- /dev/null +++ b/frontend/src/lib/utils/error-handler.ts @@ -0,0 +1,84 @@ +/** + * Utilidades para manejar errores de API en el cliente + */ +import { toast } from 'svelte-sonner'; + +export interface ApiError { + detail: string; + status: number; + isForbidden?: boolean; +} + +/** + * Maneja errores de API mostrando el toast apropiado + * @param error - El error a manejar (puede ser un objeto ApiError o un string) + * @returns true si se manejó un error, false si no había error + */ +export function handleApiError(error?: ApiError | string | null): boolean { + if (!error) return false; + + // Si es un string, convertirlo a objeto + if (typeof error === 'string') { + // Detectar si es un error 403 + if (error.includes('403') || error.toLowerCase().includes('forbidden')) { + toast.error(error, { + duration: 5000, + description: 'No tienes permisos para realizar esta acción' + }); + return true; + } + + // Otros errores en formato string + toast.error(error, { + duration: 4000 + }); + return true; + } + + // Es un objeto ApiError + if (error.isForbidden || error.status === 403) { + // Mostrar el mensaje específico del backend si está disponible + const message = error.detail || 'No tienes permisos para realizar esta acción'; + toast.error(message, { + duration: 5000, + description: error.detail ? 'Contacta a tu administrador si crees que esto es un error' : undefined + }); + return true; + } + + if (error.status === 401) { + toast.error('Sesión expirada', { + duration: 3000, + description: 'Por favor, inicia sesión nuevamente' + }); + return true; + } + + // Otros errores + toast.error(error.detail || 'Error en la operación', { + duration: 4000 + }); + return true; +} + +/** + * Hook para usar en componentes Svelte con $effect + * Muestra automáticamente un toast cuando hay un error + * + * Ejemplo de uso en +page.svelte: + * ```svelte + * + * ``` + */ +export function useErrorHandler(error?: ApiError | null) { + if (error) { + handleApiError(error); + } +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 5c8a7830..f69244ba 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -2,8 +2,18 @@ import '../app.css'; import favicon from '$lib/assets/favicon.svg'; import { Toaster } from 'svelte-sonner'; + import { page } from '$app/stores'; + import { handleApiError } from '$lib/utils/error-handler'; let { children } = $props(); + + // Detectar errores de CUALQUIER página (layout o page) + $effect(() => { + const pageData = $page.data as any; + if (pageData?.error) { + handleApiError(pageData.error); + } + }); diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index 33475637..8eec7051 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -30,7 +30,8 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { return { authenticated: true, user: userData, - companies // Pasar las compañías al cliente + companies, // Pasar las compañías al cliente + error: undefined // Agregar error opcional para compatibilidad con error-handler }; } catch (error) { // Si es un redirect, re-lanzarlo sin tocar las cookies diff --git a/frontend/src/routes/dashboard/clients_and_providers/+page.server.ts b/frontend/src/routes/dashboard/clients_and_providers/+page.server.ts index dc458a5f..03b3a4dc 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/+page.server.ts +++ b/frontend/src/routes/dashboard/clients_and_providers/+page.server.ts @@ -1,5 +1,5 @@ import type { PageServerLoad } from './$types'; -import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; +import { getAuthTokens, authenticatedFetch, handleApiResponse } from '$lib/server/api'; export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { // Esperar a que el layout padre valide/refresque el token @@ -63,16 +63,13 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { fetch ); - if (!response.ok) { - const errorText = await response.text(); - console.error('📊 [Clients&Providers] API Error:', { - status: response.status, - statusText: response.statusText, - error: errorText - }); + const result = await handleApiResponse(response); + + if (result.error) { + console.error('📊 [Clients&Providers] API Error:', result.error); return { - error: `Error ${response.status}: ${response.statusText}`, + error: result.error, items: [], total: 0, page: page, @@ -82,7 +79,7 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { }; } - const data = await response.json(); + const data = result.data; return { items: data.items || [], diff --git a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte index a2401c3e..2cc0ee23 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte @@ -12,6 +12,7 @@ import type { PageData } from './$types'; import { browser } from '$app/environment'; import { companyStore } from '$lib/stores/company.svelte'; + import type { ApiError } from '$lib/utils/error-handler'; // Los datos iniciales vienen del servidor let { data }: { data: PageData } = $props(); @@ -84,7 +85,7 @@ let totalItems = $state(data.total || 0); let loading = $state(false); let hasMore = $derived(allItems.length < totalItems); - let error = $state(data.error || null); + let error = $state(data.error || null); async function loadMore() { if (loading || !hasMore || !companyStore.activeCompany) return; @@ -205,7 +206,9 @@ Error - {error} + + {typeof error === 'string' ? error : error.detail} + {/if}