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