feat(security): implement permission checks in tenant CRUD routes and enhance API error handling

This commit is contained in:
2026-01-14 17:34:02 -06:00
parent 4f38328031
commit 86e1f7e8f6
13 changed files with 346 additions and 202 deletions

View File

@@ -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")

View File

@@ -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(

View File

@@ -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,
)

View File

@@ -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:
"""

View File

@@ -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

View File

@@ -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<T = any>(
// 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 {

View File

@@ -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,

View File

@@ -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<Response> {
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<T = any>(
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 };
}

View File

@@ -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
* <script lang="ts">
* import { handleApiError } from '$lib/utils/error-handler';
* let { data } = $props();
*
* $effect(() => {
* handleApiError(data.error);
* });
* </script>
* ```
*/
export function useErrorHandler(error?: ApiError | null) {
if (error) {
handleApiError(error);
}
}

View File

@@ -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);
}
});
</script>
<svelte:head>

View File

@@ -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

View File

@@ -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 || [],

View File

@@ -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<string | null>(data.error || null);
let error = $state<string | ApiError | null>(data.error || null);
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
@@ -205,7 +206,9 @@
<Card.Root class="border-destructive">
<Card.Header>
<Card.Title class="text-destructive">Error</Card.Title>
<Card.Description>{error}</Card.Description>
<Card.Description>
{typeof error === 'string' ? error : error.detail}
</Card.Description>
</Card.Header>
</Card.Root>
{/if}