This commit is contained in:
David Arranz 2026-07-06 13:55:06 +02:00
parent 4bba8228cc
commit 86bf1870ef
7 changed files with 201 additions and 27 deletions

View File

@ -4,6 +4,7 @@ import {
IdentityAuthSessionProvider, IdentityAuthSessionProvider,
activeCompanyStorage, activeCompanyStorage,
authTokenStorage, authTokenStorage,
getIdentityAuthSessionRuntimeHandlers,
} from "@erp/identity/client"; } from "@erp/identity/client";
import { i18n } from "@repo/i18next"; import { i18n } from "@repo/i18next";
import { LoadingOverlay } from "@repo/rdx-ui/components"; import { LoadingOverlay } from "@repo/rdx-ui/components";
@ -35,12 +36,13 @@ export const App = () => {
baseURL: import.meta.env.VITE_API_SERVER_URL, baseURL: import.meta.env.VITE_API_SERVER_URL,
getAccessToken: () => authTokenStorage.getAccessToken(), getAccessToken: () => authTokenStorage.getAccessToken(),
getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(), getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(),
onRefreshSession: () => getIdentityAuthSessionRuntimeHandlers().refreshAccessToken(),
onAuthError: () => { onAuthError: () => {
// TODO(identity-auth): siguiente incremento getIdentityAuthSessionRuntimeHandlers().clearSession();
// 1. detectar 401 recuperable
// 2. llamar refresh() if (window.location.pathname !== "/login") {
// 3. reintentar la request original window.location.replace("/login");
// 4. limpiar sesión si refresh falla }
}, },
}); });

View File

@ -5,10 +5,8 @@ import { companiesRouter, models } from "./infrastructure";
import { buildCompaniesDependencies, buildCompaniesPublicServices } from "./infrastructure/di"; import { buildCompaniesDependencies, buildCompaniesPublicServices } from "./infrastructure/di";
export type { ICompanyPublicServices } from "./application"; export type { ICompanyPublicServices } from "./application";
export * from "./application";
export * from "./domain";
export * from "./infrastructure";
export * from "./infrastructure/persistence/sequelize"; export * from "./infrastructure/persistence/sequelize";
export type CompaniesPublicServicesType = ICompanyPublicServices; export type CompaniesPublicServicesType = ICompanyPublicServices;
export const companiesAPIModule: IModuleServer = { export const companiesAPIModule: IModuleServer = {
@ -16,8 +14,15 @@ export const companiesAPIModule: IModuleServer = {
version: "1.0.0", version: "1.0.0",
dependencies: ["identity"], dependencies: ["identity"],
/**
* Fase de SETUP
* ----------------
* - Construye el dominio (una sola vez)
* - Define qué expone el módulo
* - NO conecta infraestructura
*/
async setup(params) { async setup(params) {
const { logger } = params; const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
const internal = buildCompaniesDependencies(params); const internal = buildCompaniesDependencies(params);
const companiesServices: ICompanyPublicServices = buildCompaniesPublicServices( const companiesServices: ICompanyPublicServices = buildCompaniesPublicServices(
@ -30,17 +35,44 @@ export const companiesAPIModule: IModuleServer = {
}); });
return { return {
// Modelos Sequelize del módulo
models, models,
// Servicios expuestos a otros módulos
services: { services: {
general: companiesServices, general: companiesServices, // 'companies:general'
}, },
// Implementación privada del módulo
internal, internal,
}; };
}, },
/**
* Fase de START
* -------------
* - Conecta el módulo al runtime
* - Puede usar servicios e internals ya construidos
* - NO construye dominio
*/
async start(params) { async start(params) {
const { app, baseRoutePath, logger, getInternal } = params;
// Registro de rutas HTTP
companiesRouter(params); companiesRouter(params);
logger.info("🚀 Companies module started", {
label: this.name,
});
}, },
/**
* Warmup opcional (si lo necesitas en el futuro)
* ----------------------------------------------
* warmup(params) {
* ...
* }
*/
}; };
export default companiesAPIModule; export default companiesAPIModule;

View File

@ -1,6 +1,6 @@
import axios, { type AxiosInstance, type CreateAxiosDefaults } from "axios"; 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. * Configuración necesaria para crear una instancia de Axios personalizada.
@ -15,6 +15,7 @@ export interface AxiosFactoryConfig {
*/ */
getAccessToken: () => string | null; getAccessToken: () => string | null;
getActiveCompanyId: () => string | null; getActiveCompanyId: () => string | null;
onRefreshSession?: RefreshSessionHandler;
/** /**
* Función opcional que se ejecuta cuando ocurre un error de autenticación (por ejemplo, 401). * Función opcional que se ejecuta cuando ocurre un error de autenticación (por ejemplo, 401).
@ -41,10 +42,17 @@ export const createAxiosInstance = ({
baseURL, baseURL,
getAccessToken, getAccessToken,
getActiveCompanyId, getActiveCompanyId,
onRefreshSession,
onAuthError, onAuthError,
}: AxiosFactoryConfig): AxiosInstance => { }: AxiosFactoryConfig): AxiosInstance => {
const instance = axios.create(defaultAxiosRequestConfig); const instance = axios.create(defaultAxiosRequestConfig);
instance.defaults.baseURL = baseURL; instance.defaults.baseURL = baseURL;
return setupInterceptors(instance, getAccessToken, getActiveCompanyId, onAuthError); return setupInterceptors(
instance,
getAccessToken,
getActiveCompanyId,
onRefreshSession,
onAuthError
);
}; };

View File

@ -1,5 +1,11 @@
import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios"; import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios";
export type RefreshSessionHandler = () => Promise<string | null>;
type RetryableAxiosRequestConfig = InternalAxiosRequestConfig & {
_retry?: boolean;
};
function resolvePathname(config: InternalAxiosRequestConfig, baseURL?: string) { function resolvePathname(config: InternalAxiosRequestConfig, baseURL?: string) {
const requestUrl = config.url; const requestUrl = config.url;
@ -30,6 +36,26 @@ function shouldAttachCompanyHeader(pathname: string | null) {
return !excludedPathSuffixes.some((suffix) => pathname.endsWith(suffix)); 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. * Configura interceptores para una instancia de Axios.
* *
@ -41,8 +67,27 @@ export const setupInterceptors = (
axiosInstance: AxiosInstance, axiosInstance: AxiosInstance,
getAccessToken: () => string | null, getAccessToken: () => string | null,
getActiveCompanyId: () => string | null, getActiveCompanyId: () => string | null,
onRefreshSession?: RefreshSessionHandler,
onAuthError?: () => void onAuthError?: () => void
) => { ) => {
let refreshPromise: Promise<string | null> | null = null;
const getRefreshPromise = () => {
if (!onRefreshSession) {
return Promise.resolve<string | null>(null);
}
if (!refreshPromise) {
refreshPromise = onRefreshSession()
.catch(() => null)
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
};
axiosInstance.interceptors.request.use( axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const token = getAccessToken(); const token = getAccessToken();
@ -66,14 +111,42 @@ export const setupInterceptors = (
axiosInstance.interceptors.response.use( axiosInstance.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError) => { async (error: AxiosError) => {
// 🔴 Transformamos SIEMPRE el error antes de propagarlo const originalConfig = error.config as RetryableAxiosRequestConfig | undefined;
const normalized = normalizeAxiosError(error); const pathname = originalConfig
return Promise.reject(normalized); ? resolvePathname(originalConfig, axiosInstance.defaults.baseURL)
: null;
/*if (error.response?.status === 401 && onAuthError) { if (error.response?.status !== 401 || !originalConfig) {
onAuthError(); 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);
} }
); );

View File

@ -18,6 +18,11 @@ import {
import { getCurrentSession } from "../shared/api"; import { getCurrentSession } from "../shared/api";
import type { ActiveCompany, AuthSession, CompanySession } from "../shared/entities"; import type { ActiveCompany, AuthSession, CompanySession } from "../shared/entities";
import {
resetIdentityAuthSessionRuntimeHandlers,
setIdentityAuthSessionRuntimeHandlers,
} from "./identity-auth-session.runtime";
export interface IdentityAuthSessionContextValue { export interface IdentityAuthSessionContextValue {
session: AuthSession | null; session: AuthSession | null;
account: AuthSession["account"] | null; account: AuthSession["account"] | null;
@ -30,6 +35,7 @@ export interface IdentityAuthSessionContextValue {
logout: () => Promise<void>; logout: () => Promise<void>;
restoreSession: () => Promise<AuthSession | null>; restoreSession: () => Promise<AuthSession | null>;
refreshSession: () => Promise<AuthSession | null>; refreshSession: () => Promise<AuthSession | null>;
refreshAccessToken: () => Promise<string | null>;
selectActiveCompany: (companyId: string) => ActiveCompany | null; selectActiveCompany: (companyId: string) => ActiveCompany | null;
getAuthenticatedRedirectPath: (params?: { getAuthenticatedRedirectPath: (params?: {
returnTo?: string | null; returnTo?: string | null;
@ -170,16 +176,26 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
authTokenStorage.setTokens(refreshedTokens); authTokenStorage.setTokens(refreshedTokens);
const currentAccessToken = refreshedTokens.accessToken; if (session) {
const currentRefreshToken = refreshedTokens.refreshToken; const refreshedSession: AuthSession = {
const currentSessionResponse = await getCurrentSession(dataSource, currentAccessToken); ...session,
accessToken: refreshedTokens.accessToken,
refreshToken: refreshedTokens.refreshToken,
};
setSession(refreshedSession);
return refreshedSession;
}
const currentSessionResponse = await getCurrentSession(
dataSource,
refreshedTokens.accessToken
);
const refreshedSession = mapCurrentSessionToAuthSession(currentSessionResponse, { const refreshedSession = mapCurrentSessionToAuthSession(currentSessionResponse, {
accessToken: currentAccessToken, accessToken: refreshedTokens.accessToken,
refreshToken: currentRefreshToken, refreshToken: refreshedTokens.refreshToken,
}); });
await hydrateCompanySession(currentAccessToken);
setSession(refreshedSession); setSession(refreshedSession);
return refreshedSession; return refreshedSession;
@ -187,7 +203,12 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
clearSession(); clearSession();
return null; 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( const selectActiveCompany = useCallback(
(companyId: string) => { (companyId: string) => {
@ -229,6 +250,17 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
restoreSession(); restoreSession();
}, [restoreSession]); }, [restoreSession]);
useEffect(() => {
setIdentityAuthSessionRuntimeHandlers({
clearSession,
refreshAccessToken,
});
return () => {
resetIdentityAuthSessionRuntimeHandlers();
};
}, [clearSession, refreshAccessToken]);
const value = useMemo<IdentityAuthSessionContextValue>( const value = useMemo<IdentityAuthSessionContextValue>(
() => ({ () => ({
session, session,
@ -246,6 +278,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
logout, logout,
restoreSession, restoreSession,
refreshSession, refreshSession,
refreshAccessToken,
selectActiveCompany, selectActiveCompany,
getAuthenticatedRedirectPath, getAuthenticatedRedirectPath,
clearSession, clearSession,
@ -259,6 +292,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
loginMutation.isPending, loginMutation.isPending,
logout, logout,
logoutMutation.isPending, logoutMutation.isPending,
refreshAccessToken,
refreshSession, refreshSession,
refreshSessionMutation.isPending, refreshSessionMutation.isPending,
restoreSession, restoreSession,

View File

@ -0,0 +1,25 @@
export interface IdentityAuthSessionRuntimeHandlers {
clearSession: () => void;
refreshAccessToken: () => Promise<string | null>;
}
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;
}

View File

@ -1,2 +1,2 @@
export * from "./identity-auth-session.context"; export * from "./identity-auth-session.context";
export * from "./identity-auth-session.runtime";