Files
plantillas-proyectos/frontend/src/lib/server/api.ts
Galindo97 1dbc75107f feat: enhance user avatar handling and synchronization with Workspace
- Updated sidebar component to merge user data from Keycloak and Workspace, including avatar URLs.
- Refactored nav-user component to utilize new avatar resolution logic and display user information more effectively.
- Introduced a new utility function to resolve user avatar URLs, prioritizing Workspace avatars.
- Implemented backend changes to support synchronization of user profile data, including avatar URLs from Workspace.
- Added database migration to include new fields for Workspace profile synchronization in user_tenants.
- Created a new client for fetching user profiles from Workspace.
- Updated dashboard components to reflect changes in user data structure and avatar handling.
- Removed avatar upload functionality from the profile update process, relying on Workspace for avatar management.
- Added tests for avatar resolution logic to ensure correct prioritization of avatar sources.
2026-05-08 17:03:29 -05:00

452 lines
13 KiB
TypeScript

/**
* Utilidades para llamadas a la API desde el servidor (SSR)
* Centraliza la lógica de configuración de URL, autenticación y manejo de tokens
*/
import { redirect, type Cookies } from '@sveltejs/kit';
import {
clearAccessTokenCookies,
getAccessTokenFromCookies,
setAccessTokenCookies
} from '$lib/server/access-token-cookie';
/**
* Obtiene y normaliza la URL base de la API para llamadas desde el servidor
* Automáticamente reemplaza localhost/127.0.0.1 con 'backend' para Docker
*/
export function getServerApiUrl(): string {
// Primero intentar con INTERNAL_API_URL (para llamadas server-side en Docker)
let apiUrl = process.env.INTERNAL_API_URL;
// Si no está definida, usar VITE_API_URL del entorno runtime (no import.meta.env)
if (!apiUrl) {
apiUrl = process.env.VITE_API_URL;
}
// Como último recurso, usar el valor de build-time
if (!apiUrl) {
apiUrl = import.meta.env.VITE_API_URL;
// Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR)
apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend').replace('anexo76-dev.aduanasoft.com', 'backend');
}
// Normalizar la URL: asegurar que termine con '/'
return apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`;
}
/**
* Obtiene los tokens de autenticación de las cookies
*/
export function getAuthTokens(cookies: Cookies) {
return {
accessToken: getAccessTokenFromCookies(cookies),
refreshToken: cookies.get('refresh_token')
};
}
/**
* Establece los tokens de autenticación en las cookies
*
* Política de seguridad:
* - access_token → NO HttpOnly (Bearer desde JS); si el JWT es muy grande, varias cookies fragmentadas
* - refresh_token → HttpOnly=true (JS nunca lo lee; el servidor lo maneja via /api-sveltekit/auth/silent-refresh)
*/
export function setAuthTokens(
cookies: Cookies,
accessToken: string,
refreshToken?: string
) {
setAccessTokenCookies(cookies, accessToken, {
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7 // 7 días
});
if (refreshToken) {
cookies.set('refresh_token', refreshToken, {
path: '/',
httpOnly: true, // *** HttpOnly: JS nunca lee el refresh_token ***
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 30 // 30 días
});
}
}
/**
* Limpia todos los tokens de autenticación de las cookies
*/
export function clearAuthTokens(cookies: Cookies) {
clearAccessTokenCookies(cookies);
cookies.delete('refresh_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
}
/**
* Crea headers de autorización con el token Bearer
*/
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>, tenantOverride?: string) {
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
...additionalHeaders
};
}
/**
* Intenta refrescar el token de acceso usando el refresh token
* @returns El nuevo access token o null si falla
*/
export async function refreshAccessToken(
cookies: Cookies,
fetch: typeof globalThis.fetch
): Promise<string | null> {
const { refreshToken } = getAuthTokens(cookies);
if (!refreshToken) {
return null;
}
try {
const baseUrl = getServerApiUrl();
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refresh_token: refreshToken })
});
if (!response.ok) {
return null;
}
const data = await response.json();
// Actualizar las cookies con los nuevos tokens
setAuthTokens(cookies, data.access_token, data.refresh_token);
return data.access_token;
} catch (error) {
console.error('🔄 [API] Error al refrescar token:', error);
return null;
}
}
/**
* Realiza una petición autenticada a la API con manejo automático de refresh
* @param endpoint - Endpoint relativo (ej: 'v1/auth/me')
* @param options - Opciones de fetch
* @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: 30000ms)
*/
export async function authenticatedFetch(
endpoint: string,
options: RequestInit = {},
cookies: Cookies,
fetch: typeof globalThis.fetch,
redirectUrl?: string,
timeout: number = 30000
): Promise<Response> {
try {
const baseUrl = getServerApiUrl();
let { accessToken } = getAuthTokens(cookies);
// Si no hay token, redirigir o lanzar error
if (!accessToken) {
if (redirectUrl) {
throw redirect(303, redirectUrl);
}
throw new Error('No access token available');
}
// Construir URL completa
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
// Leer tenant override de cookie SSO (flujo multi-tenant relay)
const tenantOverride = cookies.get('sso_tenant_id');
// Crear AbortController para timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
console.error(`⏱️ [API] Timeout después de ${timeout}ms:`, endpoint);
controller.abort();
}, timeout);
// Realizar la petición inicial
// Si el body es FormData, no incluir Content-Type (el navegador lo establece con el boundary)
const isFormData = options.body instanceof FormData;
const headers = isFormData
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
: createAuthHeaders(accessToken, options.headers as Record<string, string>, tenantOverride);
let response = await fetch(url, {
...options,
headers,
signal: controller.signal
});
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);
if (newToken) {
// Reintentar la petición con el nuevo token
const newController = new AbortController();
const newTimeoutId = setTimeout(() => {
console.error(`⏱️ [API] Timeout en retry después de ${timeout}ms:`, endpoint);
newController.abort();
}, timeout);
// Si el body es FormData, no incluir Content-Type
const newHeaders = isFormData
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
: createAuthHeaders(newToken, options.headers as Record<string, string>, tenantOverride);
response = await fetch(url, {
...options,
headers: newHeaders,
signal: newController.signal
});
clearTimeout(newTimeoutId);
} else {
// No se pudo refrescar, limpiar y redirigir
clearAuthTokens(cookies);
if (redirectUrl) {
throw redirect(303, redirectUrl);
}
}
}
return response;
} catch (error) {
// Si es un redirect, re-lanzarlo
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
console.error('🔴 [API] Error en authenticatedFetch:', endpoint, error);
// Retornar una respuesta de error simulada en lugar de lanzar
return new Response(JSON.stringify({ error: 'Network error', details: String(error) }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
/**
* Valida que el usuario esté autenticado y obtiene sus datos
* @returns Los datos del usuario o null si no está autenticado
*/
export async function validateAuth(
cookies: Cookies,
fetch: typeof globalThis.fetch,
redirectOnFail?: string
): Promise<any> {
const pickAvatar = (...candidates: Array<unknown>): string | null => {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim();
}
}
return null;
};
try {
const response = await authenticatedFetch(
'v1/auth/me',
{},
cookies,
fetch,
redirectOnFail
);
if (!response.ok) {
if (redirectOnFail) {
clearAuthTokens(cookies);
throw redirect(303, redirectOnFail);
}
return null;
}
const keycloakData = await response.json();
const workspaceAvatarFromAuthMe = pickAvatar(
keycloakData.avatar_url,
keycloakData.avatarUrl,
keycloakData.picture,
keycloakData.photo
);
console.debug('[avatar][validateAuth] /v1/auth/me avatar_url recibido:', workspaceAvatarFromAuthMe ?? '(null)');
// Obtener perfil adicional del usuario (avatar, bio, etc.)
try {
const profileResponse = await authenticatedFetch(
'v1/core/users/me/profile',
{},
cookies,
fetch
);
if (profileResponse.ok) {
const profileData = await profileResponse.json();
const workspaceAvatarFromProfile = pickAvatar(
profileData.workspaceAvatarUrl,
profileData.workspace_avatar_url
);
const legacyAvatar = pickAvatar(
profileData.legacyAvatarUrl,
profileData.legacy_avatar_url,
profileData.avatarUrl,
profileData.avatar_url,
profileData.avatar,
profileData.photo,
profileData.picture
);
const finalWorkspaceAvatar = pickAvatar(workspaceAvatarFromAuthMe, workspaceAvatarFromProfile);
const finalAvatar = pickAvatar(finalWorkspaceAvatar, legacyAvatar);
console.debug('[avatar][validateAuth] avatar final resuelto:', finalAvatar ?? '(null)');
// Combinar datos de Keycloak con datos del perfil.
// Prioridad para nombre: caché local del perfil > JWT claims.
return {
...keycloakData,
id: profileData.id || keycloakData.id || keycloakData.sub,
username: profileData.username || keycloakData.username || keycloakData.preferred_username || '',
email: profileData.email || keycloakData.email || '',
first_name: profileData.first_name || keycloakData.first_name || keycloakData.given_name || '',
last_name: profileData.last_name || keycloakData.last_name || keycloakData.family_name || '',
avatar_url: finalAvatar,
avatarUrl: finalAvatar,
workspace_avatar_url: finalWorkspaceAvatar,
workspaceAvatarUrl: finalWorkspaceAvatar,
legacy_avatar_url: legacyAvatar,
legacyAvatarUrl: legacyAvatar,
phone: profileData.phone || null,
bio: profileData.bio || null,
preferences: profileData.preferences || {}
};
}
} catch (profileError) {
console.warn('⚠️ [API] No se pudo cargar el perfil del usuario, usando solo datos de Keycloak');
}
// Fallback: map raw JWT claim names to the expected field names
const nameParts = (keycloakData.name || '').split(' ');
const finalAvatar = workspaceAvatarFromAuthMe;
console.debug('[avatar][validateAuth] fallback auth/me avatar final:', finalAvatar ?? '(null)');
return {
...keycloakData,
id: keycloakData.id || keycloakData.sub,
username: keycloakData.username || keycloakData.preferred_username || '',
first_name: keycloakData.first_name || keycloakData.given_name || nameParts[0] || '',
last_name: keycloakData.last_name || keycloakData.family_name || nameParts.slice(1).join(' ') || '',
avatar_url: finalAvatar,
avatarUrl: finalAvatar,
workspace_avatar_url: finalAvatar,
workspaceAvatarUrl: finalAvatar,
};
} catch (error) {
// Si es un redirect, re-lanzarlo
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
console.error('🔐 [API] Error validando autenticación:', error);
if (redirectOnFail) {
clearAuthTokens(cookies);
throw redirect(303, redirectOnFail);
}
return null;
}
}
/**
* Obtiene las compañías del usuario autenticado
*/
export async function getUserCompanies(
cookies: Cookies,
fetch: typeof globalThis.fetch
): Promise<any[]> {
try {
const response = await authenticatedFetch(
'v1/a76/company/my-companies',
{},
cookies,
fetch
);
if (!response.ok) {
console.error('🏢 [API] Error cargando compañías:', response.status);
return [];
}
return await response.json();
} catch (error) {
console.error('🏢 [API] Error cargando compañías:', error);
return [];
}
}
/**
* Obtiene el ID de la compañía activa, o la primera disponible si no hay ninguna seleccionada
*/
export async function getActiveCompanyId(
cookies: Cookies,
fetch: typeof globalThis.fetch
): Promise<string | null> {
let companyId = cookies.get('active_company_id');
// Si no hay companyId en cookie, obtener las compañías del usuario y usar la primera
if (!companyId) {
const companies = await getUserCompanies(cookies, fetch);
if (companies.length > 0) {
companyId = companies[0].id.toString();
}
}
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 };
}