| @@ -14,7 +14,7 @@ | |||
| "REACT_APP_BACKEND_PROTOCOL": "https", | |||
| "REACT_APP_BACKEND_HOST": "forms.lioner.com", | |||
| "REACT_APP_BACKEND_PORT": "8090", | |||
| "REACT_APP_ADOBE_API_KEY": "ee4433f258a74641ae6d502fd41cf703", | |||
| "REACT_APP_ADOBE_API_KEY": "7f498776ecdf47399d7e123d85b9fe1b", | |||
| "REACT_APP_BACKEND_API_PATH": "/api" | |||
| } | |||
| } | |||
| @@ -6,6 +6,8 @@ | |||
| "dependencies": { | |||
| "@ant-design/colors": "^6.0.0", | |||
| "@ant-design/icons": "^4.7.0", | |||
| "@azure/msal-browser": "^4.26.1", | |||
| "@azure/msal-react": "^3.0.21", | |||
| "@casl/ability": "^6.5.0", | |||
| "@casl/react": "^3.1.0", | |||
| "@emotion/cache": "^11.10.3", | |||
| @@ -0,0 +1,58 @@ | |||
| import axios from 'axios'; | |||
| import { InteractionRequiredAuthError } from "@azure/msal-browser"; | |||
| // ⚠️ UPDATE THIS SCOPE if needed | |||
| const apiRequest = { | |||
| scopes: ["api://d82560a8-8fac-401d-9173-10668acb7dfa/access_as_user"] | |||
| }; | |||
| /** | |||
| * Sets up an Axios interceptor to acquire an MSAL token and handle 401 errors. | |||
| */ | |||
| export const setupMsalAxiosInterceptor = (msalInstance, dispatch, handleLogoutFunction) => { | |||
| // REQUEST Interceptor: Attach the token | |||
| axios.interceptors.request.use(async (config) => { | |||
| const accounts = msalInstance.getAllAccounts(); | |||
| if (accounts.length > 0) { | |||
| apiRequest.account = accounts[0]; | |||
| try { | |||
| // Attempt to acquire the token silently | |||
| const response = await msalInstance.acquireTokenSilent(apiRequest); | |||
| config.headers.Authorization = `Bearer ${response.accessToken}`; | |||
| } catch (error) { | |||
| if (error instanceof InteractionRequiredAuthError) { | |||
| console.warn("MSAL: Silent token acquisition failed. Request sent without token."); | |||
| } else { | |||
| console.error("MSAL: Token acquisition failed.", error); | |||
| } | |||
| } | |||
| } | |||
| // Keep your custom header | |||
| config.headers['X-Authorization'] = process.env.REACT_APP_API_KEY; | |||
| return config; | |||
| }, (error) => { | |||
| return Promise.reject(error); | |||
| }); | |||
| // RESPONSE Interceptor: Handle 401 Unauthorized | |||
| axios.interceptors.response.use( | |||
| (response) => response, | |||
| async (error) => { | |||
| if (error.response && error.response.status === 401) { | |||
| console.error("401 Unauthorized received. Forcing MSAL logout."); | |||
| // 1. Clear Redux/local storage state | |||
| await dispatch(handleLogoutFunction()); | |||
| // 2. Clear MSAL session and redirect to login | |||
| msalInstance.logoutRedirect(); | |||
| } | |||
| return Promise.reject(error); | |||
| } | |||
| ); | |||
| }; | |||
| @@ -0,0 +1,44 @@ | |||
| import axios from 'axios'; | |||
| // NOTE: This scope MUST match the one used in your AutoLogoutProvider | |||
| const apiRequest = { | |||
| scopes: ["api://d82560a8-8fac-401d-9173-10668acb7dfa/access_as_user"] | |||
| }; | |||
| /** | |||
| * Sets up an Axios request interceptor to automatically acquire an MSAL token | |||
| * and attach it to the Authorization header before sending the request. | |||
| * @param {object} msalInstance - The PublicClientApplication instance from MSAL. | |||
| */ | |||
| export const setupMsalAxiosInterceptor = (msalInstance) => { | |||
| axios.interceptors.request.use(async (config) => { | |||
| // 1. Check if user is logged in | |||
| const accounts = msalInstance.getAllAccounts(); | |||
| if (accounts.length > 0) { | |||
| // Set the account for the token request | |||
| apiRequest.account = accounts[0]; | |||
| try { | |||
| // 2. Attempt to acquire the token silently (fastest way) | |||
| const response = await msalInstance.acquireTokenSilent(apiRequest); | |||
| // 3. Attach the Access Token to the Authorization header | |||
| config.headers.Authorization = `Bearer ${response.accessToken}`; | |||
| } catch (error) { | |||
| // If silent acquisition fails (e.g., token expired, or user needs interaction), | |||
| // we allow the request to proceed without a token. The response interceptor | |||
| // or the AutoLogoutProvider will handle the eventual 401/logout. | |||
| console.warn("MSAL: Silent token acquisition failed. Request sent without token.", error); | |||
| } | |||
| } | |||
| // 4. Return the (potentially modified) config | |||
| return config; | |||
| }, (error) => { | |||
| return Promise.reject(error); | |||
| }); | |||
| // You should use the existing response interceptor in auth/index.js to handle 401s | |||
| // for non-idle requests, which should call dispatch(handleLogoutFunction()) and instance.logoutRedirect(). | |||
| }; | |||
| @@ -0,0 +1,34 @@ | |||
| import { LogLevel } from '@azure/msal-browser'; | |||
| // 1. MSAL Configuration (msalConfig) | |||
| export const msalConfig = { | |||
| auth: { | |||
| // Application (client) ID of your React SPA from Azure Portal | |||
| clientId: "d82560a8-8fac-401d-9173-10668acb7dfa", | |||
| // Authority URL (e.g., https://login.microsoftonline.com/YOUR_TENANT_ID) | |||
| authority: "https://login.microsoftonline.com/cb3fc669-059d-4f8c-85eb-5c6564032c53", | |||
| // Must match the Redirect URI set in the Azure Portal | |||
| redirectUri: "http://localhost:3000/client", | |||
| }, | |||
| cache: { | |||
| cacheLocation: "sessionStorage", // Recommended location | |||
| storeAuthStateInCookie: false, | |||
| }, | |||
| system: { | |||
| loggerOptions: { | |||
| loggerCallback: (level, message, containsPii) => { | |||
| if (containsPii) { return; } | |||
| if (level === LogLevel.Error) { console.error(message); } | |||
| }, | |||
| piiLoggingEnabled: false, | |||
| logLevel: LogLevel.Warning, | |||
| } | |||
| } | |||
| }; | |||
| // 2. Scopes for your Spring Boot API (loginRequest) | |||
| export const loginRequest = { | |||
| // This scope tells Azure AD that you want permission to call your API | |||
| // The scope is typically your API's App ID URI + '/.default' | |||
| scopes: ["api://d82560a8-8fac-401d-9173-10668acb7dfa/.default"] | |||
| }; | |||
| @@ -1,5 +1,8 @@ | |||
| import React, { createContext, useState, useEffect } from 'react'; | |||
| import React, { createContext, useState, useEffect, useRef, useCallback} from 'react'; | |||
| import { useMsal, useIsAuthenticated } from "@azure/msal-react"; | |||
| import { InteractionRequiredAuthError } from "@azure/msal-browser"; | |||
| import {handleLogoutFunction} from "../auth"; | |||
| import { useDispatch } from 'react-redux'; | |||
| import {dispatch} from "../store"; | |||
| import {useNavigate} from "react-router-dom"; | |||
| import axios from "axios"; | |||
| @@ -12,136 +15,59 @@ import {ChangePasswordWindow} from "../layout/MainLayout/Header/HeaderContent/Pr | |||
| import {ThemeProvider} from "@emotion/react"; | |||
| const TimerContext = createContext(); | |||
| const IDLE_TIMEOUT_SECONDS = 3600; | |||
| const AutoLogoutProvider = ({ children }) => { | |||
| const [lastRequestTime, setLastRequestTime] = useState(Date.now()); | |||
| const navigate = useNavigate(); | |||
| const [logoutInterval, setLogoutInterval] = useState(30); | |||
| const [state, setState] = useState('Active'); | |||
| const [isTempWindowOpen, setIsTempWindowOpen] = useState(false); | |||
| const [forceChangePassword, setForceChangePassword] = useState(false); | |||
| useEffect(() => { | |||
| const userData = getUserData(); | |||
| const checked = localStorage.getItem("checkPasswordExpired"); | |||
| if(userData !== null){ | |||
| //system user | |||
| if(checked === "false"){ | |||
| axios.get(`${apiPath}${GET_USER_PASSWORD_DURATION}`,{ | |||
| params:{ | |||
| id: userData.id | |||
| } | |||
| }) | |||
| .then((response) => { | |||
| if (response.status === 200) { | |||
| setForceChangePassword(response.data.expired); | |||
| if(!response.data.expired){ | |||
| localStorage.setItem("checkPasswordExpired",true); | |||
| } | |||
| } | |||
| }) | |||
| .catch(error => { | |||
| console.log(error); | |||
| return false; | |||
| }); | |||
| } | |||
| const dispatch = useDispatch(); | |||
| // ⚡️ Use the inProgress property from useMsal to guard against premature logout | |||
| const { instance, accounts, inProgress } = useMsal(); | |||
| const isAuthenticated = accounts.length > 0; | |||
| const idleTimerRef = useRef(null); | |||
| // The core logout logic | |||
| const forceLogout = useCallback(() => { | |||
| // 🛑 CRITICAL GUARD CLAUSE: | |||
| // Do NOT log out if: | |||
| // 1. MSAL is currently busy (acquiring token, handling redirect, etc.) | |||
| // 2. No accounts are logged in (to prevent unnecessary calls) | |||
| if (inProgress !== "none" || !isAuthenticated) { | |||
| console.log("MSAL is busy or user is not authenticated. Aborting idle logout attempt."); | |||
| return; | |||
| } | |||
| }, []); | |||
| const onIdle = () => { | |||
| setLastRequestTime(Date.now()); | |||
| setState('Idle') | |||
| } | |||
| const onActive = () => { | |||
| setLastRequestTime(Date.now()); | |||
| setState('Active') | |||
| } | |||
| console.warn('Idle timeout reached. Forcing logout.'); | |||
| const { | |||
| getRemainingTime, | |||
| //getTabId, | |||
| isLastActiveTab, | |||
| } = useIdleTimer({ | |||
| onIdle, | |||
| onActive, | |||
| timeout: 60_000, | |||
| throttle: 500, | |||
| crossTab: true, | |||
| syncTimers: 200, | |||
| }) | |||
| const lastActiveTab = isLastActiveTab() === null ? 'loading' : isLastActiveTab() | |||
| //const tabId = getTabId() === null ? 'loading' : getTabId().toString() | |||
| // 1. Clear Redux/local storage state | |||
| dispatch(handleLogoutFunction()); | |||
| // 2. Perform MSAL logout (This is what triggers the Azure AD sign out page) | |||
| instance.logoutRedirect(); | |||
| }, [dispatch, instance, isAuthenticated, inProgress]); // Dependencies updated | |||
| // useEffect for monitoring activity and setting up the timer | |||
| useEffect(() => { | |||
| const userData = getUserData(); | |||
| if(!isObjEmpty(userData)){ | |||
| axios.get(`${apiPath}${GET_IDLE_LOGOUT_TIME}`, | |||
| ) | |||
| .then((response) => { | |||
| if (response.status === 200) { | |||
| setLastRequestTime(Date.now()); | |||
| setLogoutInterval(parseInt(response.data.data)); | |||
| } | |||
| }) | |||
| .catch(error => { | |||
| console.log(error); | |||
| return false; | |||
| }); | |||
| } | |||
| else{ | |||
| //navigate('/login'); | |||
| } | |||
| }, []); | |||
| console.log('AutoLogoutProvider Render/Effect:', { | |||
| isAuthenticated, | |||
| inProgress, | |||
| hasAccessToken: !!localStorage.getItem('accessToken') // Check legacy token | |||
| }); | |||
| // ... (Your existing logic to set up the idle timer and reset it on activity) | |||
| // Ensure that your timer starts ONLY if isAuthenticated is true AND inProgress is "none" | |||
| useEffect(() => { | |||
| const interval = setInterval(async () => { | |||
| const currentTime = Date.now(); | |||
| getRemainingTime(); | |||
| if(state !== "Active" && lastActiveTab){ | |||
| const timeElapsed = currentTime - lastRequestTime; | |||
| // if (timeElapsed >= logoutInterval * 60 * 1000) { | |||
| // await dispatch(handleLogoutFunction()); | |||
| // await navigate('/login'); | |||
| // await window.location.reload(); | |||
| // } | |||
| } | |||
| }, 1000); // Check every second | |||
| if (isAuthenticated && inProgress === "none") { | |||
| // Start the timer | |||
| idleTimerRef.current = setTimeout(forceLogout, IDLE_TIMEOUT_SECONDS * 1000); | |||
| } | |||
| return () => { | |||
| clearInterval(interval); | |||
| clearTimeout(idleTimerRef.current); | |||
| }; | |||
| }, [lastRequestTime,logoutInterval]); | |||
| useEffect(() => { | |||
| //if user data from parent are not null | |||
| if(forceChangePassword){ | |||
| setIsTempWindowOpen(true); | |||
| } | |||
| }, [forceChangePassword]); | |||
| const handleTempClose = (event, reason) => { | |||
| if (reason && reason === "backdropClick") | |||
| return; | |||
| setIsTempWindowOpen(false); | |||
| }; | |||
| }, [isAuthenticated, inProgress, forceLogout]); // Depend on MSAL state | |||
| return ( | |||
| <TimerContext.Provider value={{lastRequestTime,setLastRequestTime}}> | |||
| {children} | |||
| <ThemeProvider theme={LIONER_FORM_THEME}> | |||
| <ChangePasswordWindow | |||
| isWindowOpen={isTempWindowOpen} | |||
| title={"Change Password"} | |||
| onNormalClose={handleTempClose} | |||
| onConfirmClose={handleTempClose} | |||
| isForce={true} | |||
| /> | |||
| </ThemeProvider> | |||
| </TimerContext.Provider> | |||
| ); | |||
| return children; | |||
| }; | |||
| export { TimerContext, AutoLogoutProvider }; | |||
| @@ -320,106 +320,6 @@ const PdfSearchForm = ({applySearch, setExpanded,expanded, clientId}) => { | |||
| <Grid/> | |||
| } | |||
| </Grid> | |||
| {/* <Grid item> | |||
| <Button | |||
| variant="contained" | |||
| color="create" | |||
| onClick={createFormIDA} | |||
| > | |||
| New Lioner IDA | |||
| </Button> | |||
| </Grid> | |||
| <Grid item> | |||
| <Button | |||
| variant="contained" | |||
| color="create" | |||
| onClick={createFormFNA} | |||
| > | |||
| New Lioner FNA | |||
| </Button> | |||
| </Grid> | |||
| <Grid item> | |||
| <Button | |||
| variant="contained" | |||
| color="create" | |||
| onClick={createFormHSBCFIN} | |||
| > | |||
| New HSBCFIN | |||
| </Button> | |||
| </Grid> | |||
| <Grid item> | |||
| <Button | |||
| variant="contained" | |||
| color="create" | |||
| onClick={() => {}} | |||
| > | |||
| New HSBCA31 | |||
| </Button> | |||
| </Grid> | |||
| <Grid item> | |||
| <Button | |||
| variant="contained" | |||
| color="create" | |||
| onClick={() => {}} | |||
| > | |||
| New MLB035 | |||
| </Button> | |||
| </Grid> | |||
| <Grid item> | |||
| <Button | |||
| variant="contained" | |||
| color="create" | |||
| onClick={() => {}} | |||
| > | |||
| New MLFNA ENG | |||
| </Button> | |||
| </Grid> | |||
| <Grid item> | |||
| <Button | |||
| variant="contained" | |||
| color="create" | |||
| onClick={() => {}} | |||
| > | |||
| New MLFNA CHI | |||
| </Button> | |||
| </Grid> | |||
| <Grid item> | |||
| <Button | |||
| variant="contained" | |||
| color="create" | |||
| onClick={() => {}} | |||
| > | |||
| New SLFNA ENG | |||
| </Button> | |||
| </Grid> | |||
| <Grid item> | |||
| <Button | |||
| variant="contained" | |||
| color="create" | |||
| onClick={() => {}} | |||
| > | |||
| New SLFNA CHI | |||
| </Button> | |||
| </Grid> | |||
| <Grid item> | |||
| <Button | |||
| variant="contained" | |||
| color="create" | |||
| onClick={() => {}} | |||
| > | |||
| New SLGII | |||
| </Button> | |||
| </Grid> | |||
| <Grid item> | |||
| <Button | |||
| variant="contained" | |||
| color="create" | |||
| onClick={() => {}} | |||
| > | |||
| New SL Saving | |||
| </Button> | |||
| </Grid> */} | |||
| </Grid> | |||
| </ThemeProvider> | |||
| </Grid> | |||