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

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