- 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.
452 lines
13 KiB
TypeScript
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 };
|
|
}
|