From 86bf1870ef55e87d3ab3f985955ed44d351226d8 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 6 Jul 2026 13:55:06 +0200 Subject: [PATCH] . --- apps/web/src/app.tsx | 12 +-- modules/companies/src/api/index.ts | 42 +++++++-- .../axios/create-axios-instance.ts | 12 ++- .../data-source/axios/setup-interceptors.ts | 87 +++++++++++++++++-- .../context/identity-auth-session.context.tsx | 48 ++++++++-- .../context/identity-auth-session.runtime.ts | 25 ++++++ modules/identity/src/web/context/index.ts | 2 +- 7 files changed, 201 insertions(+), 27 deletions(-) create mode 100644 modules/identity/src/web/context/identity-auth-session.runtime.ts diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 3511a591..39aca1d0 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -4,6 +4,7 @@ import { IdentityAuthSessionProvider, activeCompanyStorage, authTokenStorage, + getIdentityAuthSessionRuntimeHandlers, } from "@erp/identity/client"; import { i18n } from "@repo/i18next"; import { LoadingOverlay } from "@repo/rdx-ui/components"; @@ -35,12 +36,13 @@ export const App = () => { baseURL: import.meta.env.VITE_API_SERVER_URL, getAccessToken: () => authTokenStorage.getAccessToken(), getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(), + onRefreshSession: () => getIdentityAuthSessionRuntimeHandlers().refreshAccessToken(), onAuthError: () => { - // TODO(identity-auth): siguiente incremento - // 1. detectar 401 recuperable - // 2. llamar refresh() - // 3. reintentar la request original - // 4. limpiar sesión si refresh falla + getIdentityAuthSessionRuntimeHandlers().clearSession(); + + if (window.location.pathname !== "/login") { + window.location.replace("/login"); + } }, }); diff --git a/modules/companies/src/api/index.ts b/modules/companies/src/api/index.ts index 84b12ffc..e2f778f0 100644 --- a/modules/companies/src/api/index.ts +++ b/modules/companies/src/api/index.ts @@ -5,10 +5,8 @@ import { companiesRouter, models } from "./infrastructure"; import { buildCompaniesDependencies, buildCompaniesPublicServices } from "./infrastructure/di"; export type { ICompanyPublicServices } from "./application"; -export * from "./application"; -export * from "./domain"; -export * from "./infrastructure"; export * from "./infrastructure/persistence/sequelize"; + export type CompaniesPublicServicesType = ICompanyPublicServices; export const companiesAPIModule: IModuleServer = { @@ -16,8 +14,15 @@ export const companiesAPIModule: IModuleServer = { version: "1.0.0", dependencies: ["identity"], + /** + * Fase de SETUP + * ---------------- + * - Construye el dominio (una sola vez) + * - Define qué expone el módulo + * - NO conecta infraestructura + */ async setup(params) { - const { logger } = params; + const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params; const internal = buildCompaniesDependencies(params); const companiesServices: ICompanyPublicServices = buildCompaniesPublicServices( @@ -30,17 +35,44 @@ export const companiesAPIModule: IModuleServer = { }); return { + // Modelos Sequelize del módulo models, + + // Servicios expuestos a otros módulos services: { - general: companiesServices, + general: companiesServices, // 'companies:general' }, + + // Implementación privada del módulo internal, }; }, + /** + * Fase de START + * ------------- + * - Conecta el módulo al runtime + * - Puede usar servicios e internals ya construidos + * - NO construye dominio + */ async start(params) { + const { app, baseRoutePath, logger, getInternal } = params; + + // Registro de rutas HTTP companiesRouter(params); + + logger.info("🚀 Companies module started", { + label: this.name, + }); }, + + /** + * Warmup opcional (si lo necesitas en el futuro) + * ---------------------------------------------- + * warmup(params) { + * ... + * } + */ }; export default companiesAPIModule; diff --git a/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts b/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts index 50064f25..4a536926 100644 --- a/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts +++ b/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts @@ -1,6 +1,6 @@ import axios, { type AxiosInstance, type CreateAxiosDefaults } from "axios"; -import { setupInterceptors } from "./setup-interceptors"; +import { setupInterceptors, type RefreshSessionHandler } from "./setup-interceptors"; /** * Configuración necesaria para crear una instancia de Axios personalizada. @@ -15,6 +15,7 @@ export interface AxiosFactoryConfig { */ getAccessToken: () => string | null; getActiveCompanyId: () => string | null; + onRefreshSession?: RefreshSessionHandler; /** * Función opcional que se ejecuta cuando ocurre un error de autenticación (por ejemplo, 401). @@ -41,10 +42,17 @@ export const createAxiosInstance = ({ baseURL, getAccessToken, getActiveCompanyId, + onRefreshSession, onAuthError, }: AxiosFactoryConfig): AxiosInstance => { const instance = axios.create(defaultAxiosRequestConfig); instance.defaults.baseURL = baseURL; - return setupInterceptors(instance, getAccessToken, getActiveCompanyId, onAuthError); + return setupInterceptors( + instance, + getAccessToken, + getActiveCompanyId, + onRefreshSession, + onAuthError + ); }; diff --git a/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts b/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts index 7af77da1..2bd81c3c 100644 --- a/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts +++ b/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts @@ -1,5 +1,11 @@ import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios"; +export type RefreshSessionHandler = () => Promise; + +type RetryableAxiosRequestConfig = InternalAxiosRequestConfig & { + _retry?: boolean; +}; + function resolvePathname(config: InternalAxiosRequestConfig, baseURL?: string) { const requestUrl = config.url; @@ -30,6 +36,26 @@ function shouldAttachCompanyHeader(pathname: string | null) { return !excludedPathSuffixes.some((suffix) => pathname.endsWith(suffix)); } +function shouldAttemptRefresh(pathname: string | null) { + if (!pathname) { + return false; + } + + const excludedPathSuffixes = [ + "/identity/auth/login", + "/identity/auth/refresh", + "/identity/auth/logout", + "/identity/auth/session", + "/companies/available", + ]; + + return !excludedPathSuffixes.some((suffix) => pathname.endsWith(suffix)); +} + +function isRefreshEndpoint(pathname: string | null) { + return pathname?.endsWith("/identity/auth/refresh") ?? false; +} + /** * Configura interceptores para una instancia de Axios. * @@ -41,8 +67,27 @@ export const setupInterceptors = ( axiosInstance: AxiosInstance, getAccessToken: () => string | null, getActiveCompanyId: () => string | null, + onRefreshSession?: RefreshSessionHandler, onAuthError?: () => void ) => { + let refreshPromise: Promise | null = null; + + const getRefreshPromise = () => { + if (!onRefreshSession) { + return Promise.resolve(null); + } + + if (!refreshPromise) { + refreshPromise = onRefreshSession() + .catch(() => null) + .finally(() => { + refreshPromise = null; + }); + } + + return refreshPromise; + }; + axiosInstance.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const token = getAccessToken(); @@ -66,14 +111,42 @@ export const setupInterceptors = ( axiosInstance.interceptors.response.use( (response) => response, - (error: AxiosError) => { - // 🔴 Transformamos SIEMPRE el error antes de propagarlo - const normalized = normalizeAxiosError(error); - return Promise.reject(normalized); + async (error: AxiosError) => { + const originalConfig = error.config as RetryableAxiosRequestConfig | undefined; + const pathname = originalConfig + ? resolvePathname(originalConfig, axiosInstance.defaults.baseURL) + : null; - /*if (error.response?.status === 401 && onAuthError) { - onAuthError(); - } */ + if (error.response?.status !== 401 || !originalConfig) { + return Promise.reject(normalizeAxiosError(error)); + } + + if (!shouldAttemptRefresh(pathname)) { + if (isRefreshEndpoint(pathname)) { + onAuthError?.(); + } + + return Promise.reject(normalizeAxiosError(error)); + } + + if (originalConfig._retry) { + onAuthError?.(); + return Promise.reject(normalizeAxiosError(error)); + } + + originalConfig._retry = true; + + const nextAccessToken = await getRefreshPromise(); + + if (!nextAccessToken) { + onAuthError?.(); + return Promise.reject(normalizeAxiosError(error)); + } + + originalConfig.headers = originalConfig.headers ?? {}; + originalConfig.headers.Authorization = `Bearer ${nextAccessToken}`; + + return axiosInstance.request(originalConfig); } ); diff --git a/modules/identity/src/web/context/identity-auth-session.context.tsx b/modules/identity/src/web/context/identity-auth-session.context.tsx index 20371baa..35d077fe 100644 --- a/modules/identity/src/web/context/identity-auth-session.context.tsx +++ b/modules/identity/src/web/context/identity-auth-session.context.tsx @@ -18,6 +18,11 @@ import { import { getCurrentSession } from "../shared/api"; import type { ActiveCompany, AuthSession, CompanySession } from "../shared/entities"; +import { + resetIdentityAuthSessionRuntimeHandlers, + setIdentityAuthSessionRuntimeHandlers, +} from "./identity-auth-session.runtime"; + export interface IdentityAuthSessionContextValue { session: AuthSession | null; account: AuthSession["account"] | null; @@ -30,6 +35,7 @@ export interface IdentityAuthSessionContextValue { logout: () => Promise; restoreSession: () => Promise; refreshSession: () => Promise; + refreshAccessToken: () => Promise; selectActiveCompany: (companyId: string) => ActiveCompany | null; getAuthenticatedRedirectPath: (params?: { returnTo?: string | null; @@ -170,16 +176,26 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac authTokenStorage.setTokens(refreshedTokens); - const currentAccessToken = refreshedTokens.accessToken; - const currentRefreshToken = refreshedTokens.refreshToken; - const currentSessionResponse = await getCurrentSession(dataSource, currentAccessToken); + if (session) { + const refreshedSession: AuthSession = { + ...session, + accessToken: refreshedTokens.accessToken, + refreshToken: refreshedTokens.refreshToken, + }; + setSession(refreshedSession); + return refreshedSession; + } + + const currentSessionResponse = await getCurrentSession( + dataSource, + refreshedTokens.accessToken + ); const refreshedSession = mapCurrentSessionToAuthSession(currentSessionResponse, { - accessToken: currentAccessToken, - refreshToken: currentRefreshToken, + accessToken: refreshedTokens.accessToken, + refreshToken: refreshedTokens.refreshToken, }); - await hydrateCompanySession(currentAccessToken); setSession(refreshedSession); return refreshedSession; @@ -187,7 +203,12 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac clearSession(); return null; } - }, [clearSession, dataSource, hydrateCompanySession, refreshSessionMutation]); + }, [clearSession, dataSource, refreshSessionMutation, session]); + + const refreshAccessToken = useCallback(async () => { + const refreshedSession = await refreshSession(); + return refreshedSession?.accessToken ?? null; + }, [refreshSession]); const selectActiveCompany = useCallback( (companyId: string) => { @@ -229,6 +250,17 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac restoreSession(); }, [restoreSession]); + useEffect(() => { + setIdentityAuthSessionRuntimeHandlers({ + clearSession, + refreshAccessToken, + }); + + return () => { + resetIdentityAuthSessionRuntimeHandlers(); + }; + }, [clearSession, refreshAccessToken]); + const value = useMemo( () => ({ session, @@ -246,6 +278,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac logout, restoreSession, refreshSession, + refreshAccessToken, selectActiveCompany, getAuthenticatedRedirectPath, clearSession, @@ -259,6 +292,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac loginMutation.isPending, logout, logoutMutation.isPending, + refreshAccessToken, refreshSession, refreshSessionMutation.isPending, restoreSession, diff --git a/modules/identity/src/web/context/identity-auth-session.runtime.ts b/modules/identity/src/web/context/identity-auth-session.runtime.ts new file mode 100644 index 00000000..8bf64546 --- /dev/null +++ b/modules/identity/src/web/context/identity-auth-session.runtime.ts @@ -0,0 +1,25 @@ +export interface IdentityAuthSessionRuntimeHandlers { + clearSession: () => void; + refreshAccessToken: () => Promise; +} + +const defaultHandlers: IdentityAuthSessionRuntimeHandlers = { + clearSession: () => {}, + refreshAccessToken: async () => null, +}; + +let currentHandlers: IdentityAuthSessionRuntimeHandlers = defaultHandlers; + +export function setIdentityAuthSessionRuntimeHandlers( + handlers: IdentityAuthSessionRuntimeHandlers +) { + currentHandlers = handlers; +} + +export function resetIdentityAuthSessionRuntimeHandlers() { + currentHandlers = defaultHandlers; +} + +export function getIdentityAuthSessionRuntimeHandlers() { + return currentHandlers; +} diff --git a/modules/identity/src/web/context/index.ts b/modules/identity/src/web/context/index.ts index 7b9bb6a9..f12d88f1 100644 --- a/modules/identity/src/web/context/index.ts +++ b/modules/identity/src/web/context/index.ts @@ -1,2 +1,2 @@ export * from "./identity-auth-session.context"; - +export * from "./identity-auth-session.runtime";