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,
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");
}
},
});

View File

@ -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;

View File

@ -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
);
};

View File

@ -1,5 +1,11 @@
import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios";
export type RefreshSessionHandler = () => Promise<string | null>;
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<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(
(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);
}
);

View File

@ -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<void>;
restoreSession: () => Promise<AuthSession | null>;
refreshSession: () => Promise<AuthSession | null>;
refreshAccessToken: () => Promise<string | null>;
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<IdentityAuthSessionContextValue>(
() => ({
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,

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.runtime";