chore: baseline plantilla-proyectos como base del CRM

This commit is contained in:
Aduanasoft
2026-07-14 09:03:52 -06:00
commit c3d0eedc8d
469 changed files with 69739 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
/**
* Endpoint server-side para el refresh silencioso del access token.
*
* Flujo de seguridad:
* 1. El cliente llama a POST /api-sveltekit/auth/silent-refresh con credentials:'include'
* (las cookies HttpOnly se envían automáticamente, sin que JS las lea).
* 2. Este servidor lee el refresh_token de la cookie HttpOnly.
* 3. Llama al backend FastAPI /v1/auth/refresh con el refresh_token.
* 4. Si es exitoso, actualiza las cookies HttpOnly con los nuevos tokens.
* 5. Retorna solo el access_token al cliente (el refresh_token permanece en HttpOnly).
*
* De este modo el refresh_token NUNCA toca el código JavaScript del cliente.
*/
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { getServerApiUrl, setAuthTokens } from '$lib/server/api';
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
export const POST = async ({ cookies, fetch }: RequestEvent) => {
const refreshToken = cookies.get('refresh_token');
if (!refreshToken) {
return json({ error: 'No refresh token available' }, { status: 401 });
}
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) {
// El refresh token expiró o fue invalidado por Keycloak (sesión terminada).
// Limpiar las cookies para que el servidor redirigir al login en la siguiente carga.
cookies.delete('refresh_token', { path: '/' });
clearAccessTokenCookies(cookies);
cookies.delete('active_company_id', { path: '/' });
const status = response.status === 401 ? 401 : 400;
return json({ error: 'Refresh token expired or invalid' }, { status });
}
const data = (await response.json()) as {
access_token: string;
refresh_token?: string;
expires_in?: number;
};
// Actualizar las cookies HttpOnly con los nuevos tokens
setAuthTokens(cookies, data.access_token, data.refresh_token);
// Devolver solo el access_token al cliente
return json({ access_token: data.access_token });
} catch (error) {
console.error('[silent-refresh] Error inesperado:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,98 @@
/**
* Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente.
*
* Dos modos:
* - { tenant_id } → flujo SSO relay: solo actualiza cookie sso_tenant_id (override de tenant)
* - { tenant_slug } → flujo login clásico: re-emite tokens KC para el nuevo tenant
*/
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestEvent } from '@sveltejs/kit';
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
const body = await request.json();
const { tenant_id, tenant_slug } = body as { tenant_id?: number; tenant_slug?: string };
if (!tenant_id && !tenant_slug) {
return json({ error: 'tenant_id or tenant_slug is required' }, { status: 400 });
}
const { accessToken, refreshToken } = getAuthTokens(cookies);
if (!accessToken) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
// Modo SSO relay: validar acceso vía Hub y actualizar cookie de override
if (tenant_id) {
try {
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (!tenantsRes.ok) {
return json({ error: 'Could not validate tenant access' }, { status: 403 });
}
const tenants: { id: number }[] = await tenantsRes.json();
const hasAccess = tenants.some((t) => t.id === tenant_id);
if (!hasAccess) {
return json({ error: 'Access denied to tenant' }, { status: 403 });
}
const { isSecureContext } = await import('$lib/server/workspace-auth');
const isProduction = isSecureContext();
cookies.set('sso_tenant_id', String(tenant_id), {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
cookies.set('sso_tenant_pub', String(tenant_id), {
path: '/',
httpOnly: false,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
cookies.delete('active_company_id', { path: '/' });
return json({ ok: true });
} catch (error) {
console.error('[switch-tenant] SSO mode error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}
// Modo login clásico: re-emitir tokens KC para el nuevo tenant
if (!refreshToken) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
try {
const baseUrl = getServerApiUrl();
const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
return json({ error: err.detail || 'Switch failed' }, { status: response.status });
}
const data = await response.json();
setAuthTokens(cookies, data.access_token, data.refresh_token);
cookies.delete('active_company_id', { path: '/' });
cookies.delete('sso_tenant_id', { path: '/' });
cookies.delete('sso_tenant_pub', { path: '/' });
return json({ ok: true });
} catch (error) {
console.error('[switch-tenant] Classic mode error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,49 @@
/**
* API route proxy para obtener las compañías del usuario
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
export const GET: RequestHandler = async ({ cookies, fetch }) => {
const token = getAccessTokenFromCookies(cookies);
if (!token) {
// Limpiar cualquier cookie de compañía si no hay autenticación
cookies.delete('active_company_id', { path: '/' });
return json({ error: 'No authenticated' }, { status: 401 });
}
// Configurar la URL de la API
let apiUrl = process.env.INTERNAL_API_URL;
if (!apiUrl) {
apiUrl = process.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');
}
// Normalizar la URL
const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`;
try {
const response = await fetch(`${baseUrl}v1/auth/my-companies`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
// Si la autenticación falló, limpiar la cookie de compañía
if (response.status === 401) {
cookies.delete('active_company_id', { path: '/' });
}
return json({ error: 'Failed to fetch companies' }, { status: response.status });
}
const companies = await response.json();
return json(companies);
} catch (error) {
console.error('Error fetching companies:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,29 @@
/**
* API route para establecer la compañía activa en una cookie
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies, request }) => {
try {
const { companyId } = await request.json();
if (!companyId || typeof companyId !== 'number') {
return json({ error: 'Invalid company ID' }, { status: 400 });
}
// Establecer la cookie desde el servidor
cookies.set('active_company_id', companyId.toString(), {
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 días
sameSite: 'lax',
httpOnly: false, // Permitir acceso desde JavaScript
secure: process.env.NODE_ENV === 'production'
});
return json({ success: true, companyId });
} catch (error) {
console.error('Error setting active company:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,46 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
import {
extractAllowedSystemsFromToken,
fetchAllowedSystems,
isValidSystem,
mergeTokenClaims,
setActiveSystemCookie
} from '$lib/server/system-gate';
export const POST: RequestHandler = async ({ cookies, request }) => {
try {
const { system } = await request.json();
if (!isValidSystem(system)) {
return json({ error: 'Sistema inválido' }, { status: 400 });
}
const accessToken = getAccessTokenFromCookies(cookies);
if (!accessToken) {
return json({ error: 'No autenticado' }, { status: 401 });
}
const tokenClaims = mergeTokenClaims(null, accessToken);
let allowedSystems = extractAllowedSystemsFromToken(tokenClaims);
if (allowedSystems.length === 0) {
const rawCompanyId = cookies.get('active_company_id');
const companyId = rawCompanyId ? Number.parseInt(rawCompanyId, 10) : NaN;
if (Number.isFinite(companyId)) {
allowedSystems = await fetchAllowedSystems(cookies, fetch, companyId);
}
}
if (!allowedSystems.includes(system)) {
return json({ error: 'No tienes acceso a ese sistema' }, { status: 403 });
}
setActiveSystemCookie(cookies, system);
return json({ success: true, system });
} catch {
return json({ error: 'Internal server error' }, { status: 500 });
}
};