diff --git a/src/App.tsx b/src/App.tsx index 4571a3ce2..3b05ebfd0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,10 +2,8 @@ import { useQuery } from '@tanstack/react-query'; import { MarkdownText } from 'MarkdownTest'; import { fetchUserProfile } from 'api/requests'; import useSblAuth from 'api/useSblAuth'; -import FooterCfGovWrapper from 'components/FooterCfGovWrapper'; import { LoadingApp, LoadingContent } from 'components/Loading'; import ScrollToTop from 'components/ScrollToTop'; -import { Alert, PageHeader, SkipNav } from 'design-system-react'; import 'design-system-react/style.css'; import Error500 from 'pages/Error/Error500'; import { NotFound404 } from 'pages/Error/NotFound404'; @@ -25,25 +23,17 @@ import CreateProfileFormWAssoc from 'pages/ProfileForm/Step1Form/Step1Form'; import { SummaryRoutesList } from 'pages/Summary/SummaryRoutes'; import type { ReactElement } from 'react'; import { Suspense, lazy } from 'react'; -import { - BrowserRouter, - Navigate, - Outlet, - Route, - Routes, - useLocation, -} from 'react-router-dom'; -import type { UserProfileType } from 'types/filingTypes'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; import getIsRoutingEnabled, { setIsRoutingEnabled, toggleRouting, } from 'utils/getIsRoutingEnabled'; -import { useHeaderAuthLinks } from 'utils/useHeaderAuthLinks'; import ErrorFallback from 'ErrorFallback'; // eslint-disable-next-line import/no-extraneous-dependencies import { ErrorBoundary } from 'react-error-boundary'; -import release from './constants/release.json'; +import BasicLayout from 'BasicLayout'; +import ProtectedRoute from 'ProtectedRoute'; const FilingHome = lazy(async () => import('pages/Filing/FilingHome')); const ProfileForm = lazy(async () => import('pages/ProfileForm')); @@ -73,129 +63,30 @@ if (import.meta.env.DEV) { } if (!isRoutingEnabled) console.warn('Routing is disabled!'); -function BasicLayout(): ReactElement { - const headerLinks = [...useHeaderAuthLinks()]; - const location = useLocation(); - const auth = useSblAuth(); - - if (auth.error) { - const errorMessage = auth.error.message; - - // Authentication service down - if (errorMessage.includes('Failed to fetch')) { - if (!location.pathname.includes('/500')) { - return ( - - ); - } - } - // User's session has expired - else if ( - errorMessage.includes("Session doesn't have required client") && - location.pathname !== '/' - ) { - void auth.onLogout(); - return <>; - } - } - - return ( -
-
- - {/* TODO: Move this component to the DSR for other teams' use */} - {/* See: https://github.com/cfpb/design-system-react/issues/352 */} -
-
- -
-
- - -
-
- {/* Part of fix to the white space below the footer problem */} - -
- {release.version} -
-
-
- ); -} - -interface ProtectedRouteProperties { - isAnyAuthorizationLoading: boolean; - isAuthenticated: boolean; - isLoading: boolean; - onLogin: () => Promise; - UserProfile: UserProfileType | undefined; - children: JSX.Element; -} - -function ProtectedRoute({ - isAnyAuthorizationLoading, - isAuthenticated, - isLoading: isInitialAuthorizationLoading, - onLogin, - UserProfile, - children, -}: ProtectedRouteProperties): JSX.Element { - const { pathname } = useLocation(); - const isProfileFormPath = pathname.includes('/profile/complete'); - - if (!isRoutingEnabled) { - return children; - } - - if (!isInitialAuthorizationLoading && !isAuthenticated) { - void onLogin(); - return <>; - } - - if (isAnyAuthorizationLoading) return ; - - if (!UserProfile) { - throw new Error('User Profile does not exist'); - } - - const isUserAssociatedWithAnyInstitution = - (UserProfile?.institutions?.length ?? 0) > 0; - if (!isUserAssociatedWithAnyInstitution && !isProfileFormPath) - return ; - if (isProfileFormPath && isUserAssociatedWithAnyInstitution) - return ; - return children; -} - export default function App(): ReactElement { const auth = useSblAuth(); - const { - isAuthenticated: userIsAuthenticated, - isLoading: isAuthLoading, - emailAddress, - } = auth; - const { isLoading: isFetchUserProfileLoading, data: UserProfile } = useQuery({ - queryKey: ['fetch-user-profile', emailAddress], + const { isLoading, data: UserProfile } = useQuery({ + queryKey: ['fetch-user-profile', auth.emailAddress], queryFn: async () => fetchUserProfile(auth), - enabled: !!userIsAuthenticated, + enabled: !!auth?.isAuthenticated, }); - const loadingStates = [isAuthLoading, isFetchUserProfileLoading]; - const isAnyAuthorizationLoading = loadingStates.some(Boolean); const ProtectedRouteAuthorizations = { - ...auth, + isLoading, UserProfile, - isAnyAuthorizationLoading, }; + // TODO: Future, use this to configure a logout countdown notice and confirmation + // React.useEffect(() => { + // auth.events.addAccessTokenExpiring(props => { + // console.log('Expiring:', props); + // }); + // auth.events.addAccessTokenExpired(props => { + // console.log('Expired:', props); + // }) + // }, []); + return ( diff --git a/src/BasicLayout.tsx b/src/BasicLayout.tsx new file mode 100644 index 000000000..2f879be88 --- /dev/null +++ b/src/BasicLayout.tsx @@ -0,0 +1,65 @@ +import useSblAuth from 'api/useSblAuth'; +import FooterCfGovWrapper from 'components/FooterCfGovWrapper'; +import { Alert, PageHeader, SkipNav } from 'design-system-react'; +import type { ReactElement } from 'react'; +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { useHeaderAuthLinks } from 'utils/useHeaderAuthLinks'; + +import release from './constants/release.json'; + +export default function BasicLayout(): ReactElement { + const headerLinks = [...useHeaderAuthLinks()]; + const location = useLocation(); + const auth = useSblAuth(); + + if (auth.error) { + const errorMessage = auth.error.message; + + // Authentication service down + if (errorMessage.includes('Failed to fetch')) { + if (!location.pathname.includes('/500')) { + return ( + + ); + } + } + // User's session has expired + else if ( + errorMessage.includes("Session doesn't have required client") && + location.pathname !== '/' + ) { + void auth.onLogout(); + return <>; + } + } + + return ( +
+
+ + {/* TODO: Move this component to the DSR for other teams' use */} + {/* See: https://github.com/cfpb/design-system-react/issues/352 */} +
+
+ +
+
+ + +
+
+ {/* Part of fix to the white space below the footer problem */} + +
+ {release.version} +
+
+
+ ); +} diff --git a/src/ProtectedRoute.tsx b/src/ProtectedRoute.tsx new file mode 100644 index 000000000..022de7b96 --- /dev/null +++ b/src/ProtectedRoute.tsx @@ -0,0 +1,46 @@ +import useSblAuth from 'api/useSblAuth'; +import { LoadingContent } from 'components/Loading'; +import { Navigate, useLocation } from 'react-router-dom'; +import type { UserProfileType } from 'types/filingTypes'; +import getIsRoutingEnabled from 'utils/getIsRoutingEnabled'; + +const isRoutingEnabled = getIsRoutingEnabled(); + +interface ProtectedRouteProperties { + isLoading: boolean; + UserProfile: UserProfileType | undefined; + children: JSX.Element; +} + +export default function ProtectedRoute({ + isLoading, + UserProfile, + children, +}: ProtectedRouteProperties): JSX.Element { + const auth = useSblAuth(); + const { pathname } = useLocation(); + const isProfileFormPath = pathname.includes('/profile/complete'); + + if (!isRoutingEnabled) { + return children; + } + + if (!auth.isLoading && !auth.isAuthenticated) { + void auth.onLogin(); + return <>; + } + + if (auth.isLoading || isLoading) return ; + + if (!UserProfile) { + throw new Error('User Profile does not exist'); + } + + const isUserAssociatedWithAnyInstitution = + (UserProfile?.institutions?.length ?? 0) > 0; + if (!isUserAssociatedWithAnyInstitution && !isProfileFormPath) + return ; + if (isProfileFormPath && isUserAssociatedWithAnyInstitution) + return ; + return children; +} diff --git a/src/api/axiosService.ts b/src/api/axiosService.ts index 72c709e18..eeb070ddb 100644 --- a/src/api/axiosService.ts +++ b/src/api/axiosService.ts @@ -16,14 +16,24 @@ export const getAxiosInstance = (baseUrl = ''): AxiosInstanceExtended => { 'Content-Type': 'application/json', }, }); - - newAxiosInstance.interceptors.request.use(response => { - const token = getOidcTokenOutsideOfContext(); - if (!token) return response; - response.headers.Authorization = token; - axios.defaults.headers.common.Authorization = token; - return response; - }); + newAxiosInstance.interceptors.request.use( + request => { + const token = getOidcTokenOutsideOfContext(); + if (!token) return request; + request.headers.Authorization = token; + axios.defaults.headers.common.Authorization = token; + return request; + }, + error => { + throw error; + }, + ); + newAxiosInstance.interceptors.response.use( + response => response, + error => { + throw error; + }, + ); return newAxiosInstance; }; @@ -73,5 +83,5 @@ export const request = async ({ // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter. // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const response = await axiosInstance[method](...argumentList); - return response.data as unknown as T; + return response?.data as unknown as T; }; diff --git a/src/api/useSblAuth.ts b/src/api/useSblAuth.ts index 62a2d80e3..0bdca987a 100644 --- a/src/api/useSblAuth.ts +++ b/src/api/useSblAuth.ts @@ -1,8 +1,21 @@ import type { AuthContextProps } from 'react-oidc-context'; import { useAuth } from 'react-oidc-context'; -import { One } from 'utils/constants'; +import { One, Thousand, Three } from 'utils/constants'; import { LOGOUT_REDIRECT_URL } from './common'; +const tokenExpiresIn = (token: string | undefined): number | undefined => { + if (!token) return undefined; + const parts = token.split('.'); + if (parts.length !== Three) return undefined; + const part = atob(parts[One]); + if (!part) return undefined; + const parsed = JSON.parse(part) as { exp: number }; + if (!parsed) return undefined; + const expires = parsed.exp; + if (!expires) return undefined; + return (new Date(expires * Thousand).getTime() - Date.now()) / Thousand; +}; + export interface SblAuthProperties extends AuthContextProps { onLogin: () => Promise; onLogout: () => Promise; @@ -33,6 +46,15 @@ const useSblAuth = (): SblAuthProperties => { window.logout = onLogout; } + if (!auth.isLoading) { + const accessUntil = tokenExpiresIn(auth?.user?.access_token); + const refreshUntil = tokenExpiresIn(auth?.user?.refresh_token); + + if (accessUntil !== undefined && (!refreshUntil || refreshUntil < 0)) { + void onLogout(); + } + } + return { ...auth, onLogin, diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 77478204c..07930fc2c 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -4,6 +4,7 @@ export const Zero = 0; export const NegativeOne = -1; export const One = 1; export const Two = 2; +export const Three = 3; export const MAX_RETRIES = 3; export const UPLOAD_SUBMIT_MAX_RETRIES = 2; export const Five = 5;