diff --git a/modules/identity/src/api/infrastructure/di/identity.di.ts b/modules/identity/src/api/infrastructure/di/identity.di.ts index 861950dc..d493281d 100644 --- a/modules/identity/src/api/infrastructure/di/identity.di.ts +++ b/modules/identity/src/api/infrastructure/di/identity.di.ts @@ -71,7 +71,7 @@ export type IdentityInternalDeps = { }; export const buildIdentityDependencies = (params: ModuleParams): IdentityInternalDeps => { - const { database, config, transactionContext } = params; + const { database, config, operationContext, transactionContext } = params; const transactionManager = buildTransactionManager(database, transactionContext); @@ -120,6 +120,7 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna authenticateRequest: identityAuthenticateRequest({ accessTokenVerifier, accountRepository, + operationContext, }), useCases: { loginWithPassword: () => @@ -152,6 +153,7 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna authenticateRequest: identityAuthenticateRequest({ accessTokenVerifier, accountRepository, + operationContext, }), useCases: { getCurrentAccount: () => 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 d81d57d7..29d65fe6 100644 --- a/modules/identity/src/web/context/identity-auth-session.context.tsx +++ b/modules/identity/src/web/context/identity-auth-session.context.tsx @@ -1,5 +1,7 @@ -import { useDataSource } from "@erp/core/hooks"; -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { isApiClientError } from "@erp/core/client"; +import { useDataSource } from "@erp/core/hooks"; +import type { CurrentSessionResponseDTO } from "@erp/identity/common"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import type { LoginForm } from "../login/entities"; import { @@ -16,7 +18,7 @@ import { useRefreshSessionMutation, } from "../shared"; import { getCurrentSession } from "../shared/api"; -import type { ActiveCompany, AuthSession, CompanySession } from "../shared/entities"; +import type { ActiveCompany, AuthSession, AuthTokens, CompanySession } from "../shared/entities"; import { resetIdentityAuthSessionRuntimeHandlers, @@ -61,20 +63,25 @@ const IdentityAuthSessionContext = createContext + isApiClientError(error) && error.status === 401; export function IdentityAuthSessionProvider({ children }: { children: React.ReactNode }) { const dataSource = useDataSource(); - const loginMutation = useLoginMutation(); - const logoutMutation = useLogoutMutation(); - const refreshSessionMutation = useRefreshSessionMutation(); + const loginMutation = useLoginMutation(); + const logoutMutation = useLogoutMutation(); + const refreshSessionMutation = useRefreshSessionMutation(); + const refreshSessionMutationAsync = refreshSessionMutation.mutateAsync; - const [session, setSession] = useState(null); - const [companySession, setCompanySession] = useState(EMPTY_COMPANY_SESSION); - const [isRestoringSession, setIsRestoringSession] = useState(authTokenStorage.hasAccessToken()); + const [session, setSession] = useState(null); + const [companySession, setCompanySession] = useState(EMPTY_COMPANY_SESSION); + const [isRestoringSession, setIsRestoringSession] = useState(authTokenStorage.hasAccessToken()); + const restoreInFlightRef = useRef | null>(null); const clearSession = useCallback(() => { authTokenStorage.clear(); @@ -111,35 +118,67 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac [dataSource] ); - const restoreSession = useCallback(async () => { - const tokens = authTokenStorage.getTokens(); - - if (!tokens.accessToken) { - clearSession(); - setIsRestoringSession(false); - return null; - } - - setIsRestoringSession(true); - - try { - const response = await getCurrentSession(dataSource, tokens.accessToken); - const restoredSession = mapCurrentSessionToAuthSession(response, { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken ?? "", - }); - - await hydrateCompanySession(tokens.accessToken); - setSession(restoredSession); - - return restoredSession; - } catch { - clearSession(); - return null; - } finally { - setIsRestoringSession(false); - } - }, [clearSession, dataSource, hydrateCompanySession]); + const restoreSession = useCallback(() => { + if (restoreInFlightRef.current) { + return restoreInFlightRef.current; + } + + const restorePromise = Promise.resolve().then(async () => { + const tokens = authTokenStorage.getTokens(); + + if (!tokens.accessToken) { + clearSession(); + setIsRestoringSession(false); + return null; + } + + setIsRestoringSession(true); + + try { + let activeTokens: AuthTokens = { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken ?? "", + }; + let response: CurrentSessionResponseDTO; + + try { + response = await getCurrentSession(dataSource, activeTokens.accessToken); + } catch (error) { + if (!isUnauthorizedError(error) || !activeTokens.refreshToken) { + throw error; + } + + const refreshedTokens = await refreshSessionMutationAsync({ + refreshToken: activeTokens.refreshToken, + }); + + authTokenStorage.setTokens(refreshedTokens); + activeTokens = refreshedTokens; + response = await getCurrentSession(dataSource, activeTokens.accessToken); + } + + const restoredSession = mapCurrentSessionToAuthSession(response, { + accessToken: activeTokens.accessToken, + refreshToken: activeTokens.refreshToken, + }); + + await hydrateCompanySession(activeTokens.accessToken); + setSession(restoredSession); + + return restoredSession; + } catch { + clearSession(); + return null; + } finally { + restoreInFlightRef.current = null; + setIsRestoringSession(false); + } + }); + + restoreInFlightRef.current = restorePromise; + + return restorePromise; + }, [clearSession, dataSource, hydrateCompanySession, refreshSessionMutationAsync]); const login = useCallback( async (params: LoginForm) => {