.
This commit is contained in:
parent
4bba8228cc
commit
86bf1870ef
@ -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");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -1,2 +1,2 @@
|
||||
export * from "./identity-auth-session.context";
|
||||
|
||||
export * from "./identity-auth-session.runtime";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user