From 18dbcb69750aac2fb7132ee421d0a0d870af53eb Mon Sep 17 00:00:00 2001 From: Sara Hentzel Date: Wed, 17 Jun 2026 11:28:05 -0500 Subject: [PATCH 1/6] TT-7429 handle token expired when starting apm --- src/renderer/src/Sources.tsx | 25 +++++++++++-- src/renderer/src/context/TokenProvider.tsx | 28 +++++++++++++- src/renderer/src/routes/Access.tsx | 43 +++++++++++++++++++--- src/renderer/src/routes/Loading.tsx | 29 ++++++++++++++- src/renderer/src/routes/Logout.tsx | 23 +++--------- src/renderer/src/store/orbit/actions.tsx | 11 ++++-- 6 files changed, 124 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/Sources.tsx b/src/renderer/src/Sources.tsx index 3e3df68d..da39bd57 100644 --- a/src/renderer/src/Sources.tsx +++ b/src/renderer/src/Sources.tsx @@ -34,6 +34,7 @@ import { LocalKey, orbitErr, orbitRetry, + forceLogin, } from './utils'; import { electronExport } from './store/importexport/electronExport'; import { restoreBackup } from './crud/restoreBackup'; @@ -67,12 +68,28 @@ const networkError = (ex: unknown): boolean => (ex instanceof Error && (ex.message === 'Failed to fetch' || ex.message === 'Network Error')); +const isUnauthorized = (ex: unknown): ex is IApiError => + ex instanceof Exception && (ex as IApiError).response?.status === 401; + +const handleUnauthorized = ( + tokenCtx: ITokenContext, + remote: JSONAPISource, + setOrbitRetries: (r: number) => void +) => { + setOrbitRetries(OrbitNetworkErrorRetries); + void remote.requestQueue?.clear?.(); + tokenCtx?.state?.invalidateOnlineSession(); + forceLogin(); + localStorage.setItem(LocalKey.offlineAdmin, 'false'); + return remote.requestQueue.skip(); +}; + const queryError = ({ tokenCtx, orbitError, remote, setOrbitRetries }: QueryStratErrProps) => (transform: RecordTransform, ex: unknown) => { console.log('***** api query fail', transform, ex); - if (ex instanceof Exception && (ex as IApiError).response?.status === 401) { - tokenCtx?.state?.logout(); + if (isUnauthorized(ex)) { + return handleUnauthorized(tokenCtx, remote, setOrbitRetries); } else if (networkError(ex)) { orbitError(ex as IApiError); //signal to datachanges that we've had a network error @@ -93,8 +110,8 @@ const updateError = }: PullStratErrProps) => (transform: RecordTransform, ex: unknown) => { console.log('***** api update fail', transform, ex); - if (ex instanceof Exception && (ex as IApiError).response?.status === 401) { - tokenCtx?.state?.logout(); + if (isUnauthorized(ex)) { + return handleUnauthorized(tokenCtx, remote, setOrbitRetries); } else if (networkError(ex)) { if (orbitRetries > 0) { setOrbitRetries(orbitRetries - 1); diff --git a/src/renderer/src/context/TokenProvider.tsx b/src/renderer/src/context/TokenProvider.tsx index d2fe4ad3..9945b513 100644 --- a/src/renderer/src/context/TokenProvider.tsx +++ b/src/renderer/src/context/TokenProvider.tsx @@ -21,6 +21,7 @@ const initState = { expiresAt: 0 as number | null, email_verified: false as boolean | undefined, logout: () => {}, + invalidateOnlineSession: () => {}, resetExpiresAt: () => {}, authenticated: () => false, // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -45,6 +46,7 @@ function TokenProvider(props: IProps) { const { getAccessTokenSilently, loginWithRedirect, + logout: auth0Logout, user, isLoading, isAuthenticated, @@ -63,8 +65,10 @@ function TokenProvider(props: IProps) { ...initState, }); const expiresAtRef = useRef(null); + const skipAuthRestoreRef = useRef(false); const getGlobal = useGetGlobal(); const setAuthSession = (profile: User | undefined, accessToken: string) => { + skipAuthRestoreRef.current = false; if (accessToken) { const decodedToken = jwtDecode(accessToken) as IToken; expiresAtRef.current = decodedToken.exp; @@ -83,7 +87,12 @@ function TokenProvider(props: IProps) { React.useEffect(() => { //this is only called on web - if (isAuthenticated && user) { + if (!isAuthenticated) { + skipAuthRestoreRef.current = false; + return; + } + if (skipAuthRestoreRef.current) return; + if (user) { getAccessTokenSilently() .then((token) => { updateOrbitToken(token); @@ -98,14 +107,28 @@ function TokenProvider(props: IProps) { }, [isAuthenticated, user]); const logout = () => { + skipAuthRestoreRef.current = true; + expiresAtRef.current = null; + localStorage.removeItem(LocalKey.loggedIn); setState((state) => ({ ...state, accessToken: null, profile: undefined, - expiresAt: 0, + expiresAt: -1, + email_verified: false, })); }; + const invalidateOnlineSession = () => { + logout(); + localStorage.removeItem(LocalKey.goingOnline); + if (isElectron) { + void ipc?.logout(); + } else { + auth0Logout({ returnTo: window.location.origin } as RedirectLoginOptions); + } + }; + const authenticated = () => { if (!state.email_verified) return false; if (timeUntilExpire() < 0) return false; @@ -250,6 +273,7 @@ function TokenProvider(props: IProps) { ...state, setAuthSession, logout, + invalidateOnlineSession, authenticated, resetExpiresAt, }, diff --git a/src/renderer/src/routes/Access.tsx b/src/renderer/src/routes/Access.tsx index 30e7983a..c539a63b 100644 --- a/src/renderer/src/routes/Access.tsx +++ b/src/renderer/src/routes/Access.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useContext } from 'react'; +import React, { useState, useEffect, useContext, useRef } from 'react'; import { useGlobal, useGetGlobal } from '../context/useGlobal'; import { useLocation } from 'react-router-dom'; import { useAuth0, RedirectLoginOptions } from '@auth0/auth0-react'; @@ -109,6 +109,8 @@ export function Access() { const [user] = useGlobal('user'); const [, setOfflineOnly] = useGlobal('offlineOnly'); + const [, setRemoteBusy] = useGlobal('remoteBusy'); + const [, setCompleted] = useGlobal('progress'); const getGlobal = useGetGlobal(); const tokenCtx = useContext(TokenContext); const { logout, accessToken, expiresAt } = tokenCtx.state; @@ -128,6 +130,7 @@ export function Access() { ); const [goOnlineConfirmation, setGoOnlineConfirmation] = useState>(); + const reloginRef = useRef(false); const checkOnline = useCheckOnline('Access'); const handleModeChange = (mode: ListMode) => { setListMode(mode); @@ -274,8 +277,8 @@ export function Access() { resetProject(); checkOnline(() => {}, true); - if (!tokenCtx.state.authenticated() && !isAuthenticated) { - if (!offline && !isElectron) { + if (!tokenCtx.state.authenticated() && !offline && !isElectron) { + if (!isAuthenticated) { const hasUsed = localStorage.key(0) !== null; if (hasUsed) { loginWithRedirect(); @@ -286,6 +289,8 @@ export function Access() { : ({ login_hint: 'signUp' } as RedirectLoginOptions); loginWithRedirect(opts); } + } else if (!accessToken) { + tokenCtx.state.invalidateOnlineSession(); } } if (user && expiresAt !== -1) { @@ -294,7 +299,7 @@ export function Access() { } else { waitForIt( 'check if token is set', - () => accessToken !== undefined, + () => Boolean(accessToken), () => false, 200 ).then(() => { @@ -306,7 +311,7 @@ export function Access() { } } /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [accessToken]); + }, [accessToken, isAuthenticated, offline]); useEffect(() => { if (isElectron && selectedUser === '') { @@ -358,6 +363,32 @@ export function Access() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [curUser]); + useEffect(() => { + if (expiresAt === -1) { + setSelectedUser(''); + localStorage.removeItem(LocalKey.goingOnline); + reloginRef.current = false; + setRemoteBusy(false); + setCompleted(0); + } + }, [expiresAt, setRemoteBusy, setCompleted]); + + useEffect(() => { + if ( + isElectron && + !offline && + whichUsers.startsWith('online') && + expiresAt === -1 && + !tokenCtx.state.authenticated() && + !localStorage.getItem(LocalKey.goingOnline) && + !reloginRef.current + ) { + reloginRef.current = true; + handleGoOnline(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expiresAt, whichUsers, offline]); + if (tokenCtx.state.accessToken && !tokenCtx.state.email_verified) { if (localStorage.getItem(LocalKey.loggedIn) === 'true') navigate('/emailunverified'); @@ -365,7 +396,7 @@ export function Access() { } else if ( (!isElectron && tokenCtx.state.authenticated()) || getGlobal('offlineOnly') || - (isElectron && selectedUser !== '') + (isElectron && selectedUser !== '' && tokenCtx.state.authenticated()) ) { setTimeout(() => navigate('/loading'), 200); } else if (/Logout/i.test(view)) { diff --git a/src/renderer/src/routes/Loading.tsx b/src/renderer/src/routes/Loading.tsx index db71ad60..0804b3c4 100644 --- a/src/renderer/src/routes/Loading.tsx +++ b/src/renderer/src/routes/Loading.tsx @@ -44,7 +44,7 @@ import { findRecord, } from '../crud'; import { useSnackBar } from '../hoc/SnackBar'; -import { API_CONFIG, isElectron } from '../../api-variable'; +import { API_CONFIG, isElectron, OrbitNetworkErrorRetries } from '../../api-variable'; import AppHead from '../components/App/AppHead'; import { useOfflnProjRead } from '../crud/useOfflnProjRead'; import ImportTab from '../components/ImportTab'; @@ -83,6 +83,7 @@ export function Loading() { const [user, setUser] = useGlobal('user'); const [, setLang] = useGlobal('lang'); const [orbitRetries, setOrbitRetries] = useGlobal('orbitRetries'); //verified this is not used in a function 2/18/25 + const [, setRemoteBusy] = useGlobal('remoteBusy'); const [errorReporter] = useGlobal('errorReporter'); const [, setProjectsLoaded] = useGlobal('projectsLoaded'); const [loadComplete, setLoadComplete] = useGlobal('loadComplete'); @@ -111,6 +112,21 @@ export function Loading() { const mounted = useRef(0); const getGlobal = useGetGlobal(); const forceDataChanges = useDataChanges(); + + const handleAuthFailure = () => { + forceLogin(); + localStorage.removeItem(LocalKey.goingOnline); + setCompleted(0); + setRemoteBusy(false); + setUser(''); + setOrbitRetries(OrbitNetworkErrorRetries); + void remote?.requestQueue?.clear?.(); + tokenCtx.state.invalidateOnlineSession(); + if (isElectron) { + navigate('/access/online'); + } + }; + //remote is passed in because it wasn't always available in global const InviteUser = async (newremote: JSONAPISource, userEmail: string) => { const inviteId = localStorage.getItem('inviteId'); @@ -349,6 +365,9 @@ export function Loading() { LoadComplete(); }); }); + }) + .catch(() => { + handleAuthFailure(); }); }; const processBackup = async () => { @@ -382,7 +401,13 @@ export function Loading() { setView('/logout'); }; - if (!offline && !authenticated()) navigate('/'); + useEffect(() => { + if (!offline && !authenticated()) { + handleAuthFailure(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [offline, accessToken, profile]); + if (view !== '') navigate(view); return ( diff --git a/src/renderer/src/routes/Logout.tsx b/src/renderer/src/routes/Logout.tsx index 45c8984c..00f1df62 100644 --- a/src/renderer/src/routes/Logout.tsx +++ b/src/renderer/src/routes/Logout.tsx @@ -56,27 +56,14 @@ export function Logout() { }, [pathname]); useEffect(() => { - let timer: NodeJS.Timeout | null = null; setLanguage(localeDefault(isDeveloper)); fetchLocalization(); if (!isElectron) { - // ctx.logout(); - if (user) { - logout({ returnTo: window.origin } as RedirectLoginOptions); - } else { - timer = setTimeout(() => { - console.log(`timer fired path=${curPath.current}`); - if (curPath.current === '/logout') { - logout({ returnTo: window.origin } as RedirectLoginOptions); - } - }, 4000); - } - } else handleLogout(); - return () => { - if (timer) { - clearTimeout(timer); - } - }; + ctx.logout(); + logout({ returnTo: window.origin } as RedirectLoginOptions); + } else { + handleLogout(); + } /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []); diff --git a/src/renderer/src/store/orbit/actions.tsx b/src/renderer/src/store/orbit/actions.tsx index 67c19a47..2672c5c6 100644 --- a/src/renderer/src/store/orbit/actions.tsx +++ b/src/renderer/src/store/orbit/actions.tsx @@ -97,7 +97,12 @@ export const fetchOrbitData = offlineSetup, showMessage, forceDataChanges - ).then((fr) => { - dispatch({ type: FETCH_ORBIT_DATA, payload: fr }); - }); + ) + .then((fr) => { + dispatch({ type: FETCH_ORBIT_DATA, payload: fr }); + }) + .catch((ex: IApiError) => { + if (ex?.response?.status === 401) return; + dispatch(orbitError(ex)); + }); }; From b23a6499a5291139c1d717b9e0fed028dad79a66 Mon Sep 17 00:00:00 2001 From: Sara Hentzel Date: Wed, 17 Jun 2026 12:07:41 -0500 Subject: [PATCH 2/6] try to prevent the "do you want to stay logged in" dialog from unloading everything else --- src/renderer/src/context/TokenProvider.tsx | 32 ++++++++++++---------- src/renderer/src/routes/Access.tsx | 8 ++++-- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/context/TokenProvider.tsx b/src/renderer/src/context/TokenProvider.tsx index 9945b513..93ec57c0 100644 --- a/src/renderer/src/context/TokenProvider.tsx +++ b/src/renderer/src/context/TokenProvider.tsx @@ -207,12 +207,16 @@ function TokenProvider(props: IProps) { if (secondsLeft < Expires + 30) { setSecondsToExpire(secondsLeft); if (!modalOpen) { + view.current = ''; setModalOpen(true); } else { view.current = ''; } } else { - if (modalOpen) setModalOpen(false); + if (modalOpen) { + view.current = ''; + setModalOpen(false); + } } } } @@ -237,6 +241,13 @@ function TokenProvider(props: IProps) { } }; + React.useEffect(() => { + if (modalOpen && view.current === '' && secondsToExpire < Expires) { + handleLogOut(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modalOpen, secondsToExpire]); + if (isLoading && !isElectron) { return ; } @@ -250,22 +261,10 @@ function TokenProvider(props: IProps) { return ; } - if (modalOpen && view.current === '') { - if (secondsToExpire < Expires) { - handleLogOut(); - } - return ( - - ); - } else if (view.current === 'Logout') { + if (view.current === 'Logout') { handleLogOut(); } - // If there is no error just render the children component. return ( {children} + ); } diff --git a/src/renderer/src/routes/Access.tsx b/src/renderer/src/routes/Access.tsx index c539a63b..ebdf140e 100644 --- a/src/renderer/src/routes/Access.tsx +++ b/src/renderer/src/routes/Access.tsx @@ -102,7 +102,7 @@ export function Access() { dispatch(action.setLanguage(lang) as any); const { pathname } = useLocation(); const navigate = useMyNavigate(); - const { loginWithRedirect, isAuthenticated } = useAuth0(); + const { loginWithRedirect, isAuthenticated, isLoading } = useAuth0(); //might need to add this to dependancy arrays? const [offline, setOffline] = useGlobal('offline'); //verified this is not used in a function 2/18/25 const [isDeveloper] = useGlobal('developer'); @@ -289,7 +289,9 @@ export function Access() { : ({ login_hint: 'signUp' } as RedirectLoginOptions); loginWithRedirect(opts); } - } else if (!accessToken) { + } else if (!accessToken && expiresAt === -1 && !isLoading) { + // Auth0 says logged in but local session was cleared (e.g. 401) — not + // the normal post-callback window before getAccessTokenSilently resolves. tokenCtx.state.invalidateOnlineSession(); } } @@ -311,7 +313,7 @@ export function Access() { } } /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [accessToken, isAuthenticated, offline]); + }, [accessToken, isAuthenticated, offline, expiresAt, isLoading]); useEffect(() => { if (isElectron && selectedUser === '') { From 825a5b36d386aaf16e220b5cafebbd6e51d3849e Mon Sep 17 00:00:00 2001 From: Sara Hentzel Date: Wed, 17 Jun 2026 12:10:11 -0500 Subject: [PATCH 3/6] safer error handling --- src/renderer/src/store/orbit/actions.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/store/orbit/actions.tsx b/src/renderer/src/store/orbit/actions.tsx index 2672c5c6..ce1dc122 100644 --- a/src/renderer/src/store/orbit/actions.tsx +++ b/src/renderer/src/store/orbit/actions.tsx @@ -9,13 +9,14 @@ import { } from './types'; import Coordinator from '@orbit/coordinator'; import { Sources } from '../../Sources'; -import { Severity } from '../../utils'; +import { Severity, orbitErr } from '../../utils'; import { OfflineProject, Plan, VProject } from '../../model'; import { ITokenContext } from '../../context/TokenProvider'; import { AlertSeverity } from '../../hoc/SnackBar'; export const orbitError = (ex: IApiError) => { - return ex.response.status !== Severity.retry + const status = ex?.response?.status; + return status !== Severity.retry ? { type: ORBIT_ERROR, payload: ex, @@ -101,8 +102,17 @@ export const fetchOrbitData = .then((fr) => { dispatch({ type: FETCH_ORBIT_DATA, payload: fr }); }) - .catch((ex: IApiError) => { - if (ex?.response?.status === 401) return; - dispatch(orbitError(ex)); + .catch((ex: unknown) => { + const apiEx = ex as IApiError; + if (apiEx?.response?.status === 401) return; + if (apiEx?.response?.status != null) { + dispatch(orbitError(apiEx)); + } else { + dispatch( + orbitError( + orbitErr(ex instanceof Error ? ex : null, 'fetch orbit data') + ) + ); + } }); }; From fed9a00ebd2b901bd20946264c24a3c8390f0919 Mon Sep 17 00:00:00 2001 From: Sara Hentzel Date: Wed, 17 Jun 2026 12:15:21 -0500 Subject: [PATCH 4/6] match interval check to new expiresAt --- src/renderer/src/Sources.tsx | 2 +- src/renderer/src/context/TokenProvider.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/Sources.tsx b/src/renderer/src/Sources.tsx index da39bd57..df2e429e 100644 --- a/src/renderer/src/Sources.tsx +++ b/src/renderer/src/Sources.tsx @@ -77,7 +77,7 @@ const handleUnauthorized = ( setOrbitRetries: (r: number) => void ) => { setOrbitRetries(OrbitNetworkErrorRetries); - void remote.requestQueue?.clear?.(); + void remote.requestQueue.clear(); tokenCtx?.state?.invalidateOnlineSession(); forceLogin(); localStorage.setItem(LocalKey.offlineAdmin, 'false'); diff --git a/src/renderer/src/context/TokenProvider.tsx b/src/renderer/src/context/TokenProvider.tsx index 93ec57c0..358ef69d 100644 --- a/src/renderer/src/context/TokenProvider.tsx +++ b/src/renderer/src/context/TokenProvider.tsx @@ -224,7 +224,7 @@ function TokenProvider(props: IProps) { useInterval( checkTokenExpired, - state?.expiresAt && !getGlobal('offline') ? 5000 : null + (state?.expiresAt ?? 0) > 0 && !getGlobal('offline') ? 5000 : null ); const handleClose = (value: number) => { From 85541badaeea226776af2703284821d802c2b976 Mon Sep 17 00:00:00 2001 From: Sara Hentzel Date: Wed, 17 Jun 2026 12:27:09 -0500 Subject: [PATCH 5/6] copilot review --- src/renderer/src/context/TokenProvider.tsx | 6 +----- src/renderer/src/routes/Loading.tsx | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/context/TokenProvider.tsx b/src/renderer/src/context/TokenProvider.tsx index 358ef69d..650eb6b7 100644 --- a/src/renderer/src/context/TokenProvider.tsx +++ b/src/renderer/src/context/TokenProvider.tsx @@ -230,7 +230,7 @@ function TokenProvider(props: IProps) { const handleClose = (value: number) => { setModalOpen(false); if (value < 0) { - view.current = 'Logout'; + handleLogOut(); } else { resetExpiresAt(); setState((state) => ({ @@ -261,10 +261,6 @@ function TokenProvider(props: IProps) { return ; } - if (view.current === 'Logout') { - handleLogOut(); - } - return ( { - handleAuthFailure(); + .catch((ex: unknown) => { + const apiEx = ex as IApiError; + if (apiEx?.response?.status === 401) { + handleAuthFailure(); + return; + } + if (apiEx?.response?.status != null) { + doOrbitError(apiEx); + } else { + doOrbitError( + orbitErr(ex instanceof Error ? ex : null, 'fetch user') + ); + } }); }; const processBackup = async () => { From 97e61b8feb71d1250aac971a3f60a68e24ee08b4 Mon Sep 17 00:00:00 2001 From: Sara Hentzel Date: Wed, 17 Jun 2026 14:05:18 -0500 Subject: [PATCH 6/6] logout if it continues to get errors rather than hang on splash screen --- src/renderer/src/Sources.tsx | 218 +++++++++++------- src/renderer/src/context/TokenProvider.tsx | 27 ++- src/renderer/src/crud/useUpdateOrbitToken.ts | 16 +- src/renderer/src/hoc/PrivateRoute.tsx | 3 +- src/renderer/src/routes/Access.tsx | 13 +- src/renderer/src/routes/Loading.tsx | 47 +++- src/renderer/src/store/orbit/actions.tsx | 6 +- src/renderer/src/utils/index.ts | 2 + src/renderer/src/utils/infoMsg.ts | 12 + src/renderer/src/utils/removeOrbitRemote.ts | 30 +++ .../src/utils/syncRemoteAuthHeaders.ts | 16 ++ 11 files changed, 275 insertions(+), 115 deletions(-) create mode 100644 src/renderer/src/utils/removeOrbitRemote.ts create mode 100644 src/renderer/src/utils/syncRemoteAuthHeaders.ts diff --git a/src/renderer/src/Sources.tsx b/src/renderer/src/Sources.tsx index df2e429e..4651cbd1 100644 --- a/src/renderer/src/Sources.tsx +++ b/src/renderer/src/Sources.tsx @@ -19,7 +19,7 @@ import IndexedDBBucket from '@orbit/indexeddb-bucket'; import JSONAPISource from '@orbit/jsonapi'; import { RecordOperation, RecordTransform } from '@orbit/records'; import { NetworkError } from '@orbit/jsonapi'; -import { Bucket, Exception } from '@orbit/core'; +import { Bucket } from '@orbit/core'; import Memory from '@orbit/memory'; import { ITokenContext } from './context/TokenProvider'; import { @@ -35,7 +35,10 @@ import { orbitErr, orbitRetry, forceLogin, + syncRemoteAuthHeaders, + getHttpStatus, } from './utils'; +import { removeOrbitRemote } from './utils/removeOrbitRemote'; import { electronExport } from './store/importexport/electronExport'; import { restoreBackup } from './crud/restoreBackup'; import { AlertSeverity } from './hoc/SnackBar'; @@ -53,43 +56,116 @@ interface PullStratErrProps { setOrbitRetries: (r: number) => void; showMessage: (msg: string | React.JSX.Element, alert?: AlertSeverity) => void; memory: Memory; - remote: JSONAPISource; + coordinator: Coordinator; + fingerprint: string; orbitRetries: number; errorReporter: typeof Bugsnag | undefined; } interface QueryStratErrProps { tokenCtx: ITokenContext; orbitError: (ex: IApiError) => void; - remote: JSONAPISource; + coordinator: Coordinator; + fingerprint: string; setOrbitRetries: (r: number) => void; } +let unauthorizedRetryAttempted = false; + const networkError = (ex: unknown): boolean => ex instanceof NetworkError || (ex instanceof Error && (ex.message === 'Failed to fetch' || ex.message === 'Network Error')); -const isUnauthorized = (ex: unknown): ex is IApiError => - ex instanceof Exception && (ex as IApiError).response?.status === 401; +const isUnauthorized = (ex: unknown): boolean => getHttpStatus(ex) === 401; + +const skipRemoteQueue = async (remote: JSONAPISource) => { + const len = remote?.requestQueue?.length ?? 0; + if (len > 0) { + try { + await remote.requestQueue.skip(); + } catch { + // queue may already be settling + } + } +}; + +const addRemoteLinkStrategies = (coordinator: Coordinator) => { + if (!coordinator.strategyNames.includes('remote-request')) + coordinator.addStrategy( + new RequestStrategy({ + name: 'remote-request', + source: 'memory', + on: 'beforeQuery', + target: 'remote', + action: 'query', + blocking: false, + }) + ); + if (!coordinator.strategyNames.includes('remote-update')) + coordinator.addStrategy( + new RequestStrategy({ + name: 'remote-update', + source: 'memory', + on: 'beforeUpdate', + target: 'remote', + action: 'update', + blocking: false, + }) + ); + if (!coordinator.strategyNames.includes('remote-sync')) + coordinator.addStrategy( + new SyncStrategy({ + name: 'remote-sync', + source: 'remote', + target: 'memory', + blocking: true, + }) + ); +}; const handleUnauthorized = ( tokenCtx: ITokenContext, - remote: JSONAPISource, + coordinator: Coordinator, + fingerprint: string, setOrbitRetries: (r: number) => void ) => { + const remote = coordinator?.getSource('remote') as JSONAPISource; + const datachangeremote = coordinator?.getSource( + 'datachanges' + ) as JSONAPISource; + const token = tokenCtx?.state?.accessToken; + if (token && remote && !unauthorizedRetryAttempted) { + unauthorizedRetryAttempted = true; + syncRemoteAuthHeaders(remote, token, fingerprint); + syncRemoteAuthHeaders(datachangeremote, token, fingerprint); + return remote.requestQueue.retry; + } + unauthorizedRetryAttempted = false; setOrbitRetries(OrbitNetworkErrorRetries); - void remote.requestQueue.clear(); tokenCtx?.state?.invalidateOnlineSession(); forceLogin(); localStorage.setItem(LocalKey.offlineAdmin, 'false'); + void skipRemoteQueue(remote); return remote.requestQueue.skip(); }; const queryError = - ({ tokenCtx, orbitError, remote, setOrbitRetries }: QueryStratErrProps) => + ({ + tokenCtx, + orbitError, + coordinator, + fingerprint, + setOrbitRetries, + }: QueryStratErrProps) => (transform: RecordTransform, ex: unknown) => { + const remote = coordinator?.getSource('remote') as JSONAPISource; console.log('***** api query fail', transform, ex); if (isUnauthorized(ex)) { - return handleUnauthorized(tokenCtx, remote, setOrbitRetries); + return handleUnauthorized( + tokenCtx, + coordinator, + fingerprint, + setOrbitRetries + ); } else if (networkError(ex)) { orbitError(ex as IApiError); //signal to datachanges that we've had a network error @@ -105,13 +181,20 @@ const updateError = setOrbitRetries, showMessage, memory, - remote, + coordinator, + fingerprint, orbitRetries, }: PullStratErrProps) => (transform: RecordTransform, ex: unknown) => { + const remote = coordinator?.getSource('remote') as JSONAPISource; console.log('***** api update fail', transform, ex); if (isUnauthorized(ex)) { - return handleUnauthorized(tokenCtx, remote, setOrbitRetries); + return handleUnauthorized( + tokenCtx, + coordinator, + fingerprint, + setOrbitRetries + ); } else if (networkError(ex)) { if (orbitRetries > 0) { setOrbitRetries(orbitRetries - 1); @@ -228,27 +311,39 @@ export const Sources = async ( const offline = !tokenState.accessToken; if (!offline) { - remote = coordinator.sourceNames.includes('remote') - ? (coordinator?.getSource('remote') as JSONAPISource) - : new JSONAPISource({ - schema: memory?.schema, - keyMap: memory?.keyMap, - bucket, - name: 'remote', - namespace: 'api', - host: API_CONFIG.host, - serializerSettingsFor: serializersSettings(), - defaultFetchSettings: { - headers: { - Authorization: 'Bearer ' + (tokenState.accessToken || ''), - 'X-FP': fingerprint, - }, - timeout: 100000, - }, - defaultTransformOptions: { - useRemoteId: true, - }, - }); + unauthorizedRetryAttempted = false; + if (coordinator.sourceNames.includes('remote')) { + await removeOrbitRemote(coordinator, false); + } + if (coordinator.activated) { + await coordinator.deactivate(); + } + remote = new JSONAPISource({ + schema: memory?.schema, + keyMap: memory?.keyMap, + ...(isElectron ? { bucket } : {}), + name: 'remote', + namespace: 'api', + host: API_CONFIG.host, + serializerSettingsFor: serializersSettings(), + defaultFetchSettings: { + headers: { + Authorization: 'Bearer ' + (tokenState.accessToken || ''), + 'X-FP': fingerprint, + }, + timeout: 100000, + }, + defaultTransformOptions: { + useRemoteId: true, + }, + }); + try { + await remote.activated; + } catch (ex) { + if (isUnauthorized(ex)) { + await skipRemoteQueue(remote); + } + } if (!coordinator.sourceNames.includes('remote')) { coordinator.addSource(remote); } @@ -264,7 +359,8 @@ export const Sources = async ( action: queryError({ tokenCtx, orbitError, - remote, + coordinator, + fingerprint, setOrbitRetries, }) as unknown as StategyError, blocking: true, @@ -283,60 +379,15 @@ export const Sources = async ( setOrbitRetries, showMessage, memory, - remote, + coordinator, + fingerprint, orbitRetries, errorReporter, }) as unknown as StategyError, blocking: true, }) ); - // Query the remote server whenever the memory is queried - if (!coordinator.strategyNames.includes('remote-request')) - coordinator.addStrategy( - new RequestStrategy({ - name: 'remote-request', - - source: 'memory', - on: 'beforeQuery', - - target: 'remote', - action: 'query', - - blocking: false, - }) - ); - - // Trap error updating data (token expired or offline) - // See: https://github.com/orbitjs/todomvc-ember-orbit - - // Update the remote server whenever the memory is updated - if (!coordinator.strategyNames.includes('remote-update')) - coordinator.addStrategy( - new RequestStrategy({ - name: 'remote-update', - - source: 'memory', - on: 'beforeUpdate', - - target: 'remote', - action: 'update', - - blocking: false, - }) - ); - - // Sync all changes received from the remote server to the memory - if (!coordinator.strategyNames.includes('remote-sync')) - coordinator.addStrategy( - new SyncStrategy({ - name: 'remote-sync', - - source: 'remote', - target: 'memory', - - blocking: true, - }) - ); + addRemoteLinkStrategies(coordinator); datachangeremote = coordinator.sourceNames.includes('datachanges') ? (coordinator?.getSource('datachanges') as JSONAPISource) @@ -431,10 +482,13 @@ export const Sources = async ( /* set the user from the token - must be done after the backup is loaded and after changes to offline are recorded */ if (!offline) { console.log(`Activating remote for user: ${tokData.sub}`); + await skipRemoteQueue(remote); await remote.activated; console.log(`Activated remote for user: ${tokData.sub}`); let uRecs = (await remote.query((q) => - q.findRecords('user').filter({ attribute: 'auth0Id', value: tokData.sub }) + q + .findRecords('user') + .filter({ attribute: 'auth0Id', value: tokData.sub }) )) as UserD[]; console.log(`has user rec: ${tokData.sub}`); if (!Array.isArray(uRecs)) uRecs = [uRecs]; diff --git a/src/renderer/src/context/TokenProvider.tsx b/src/renderer/src/context/TokenProvider.tsx index 650eb6b7..fb94d0af 100644 --- a/src/renderer/src/context/TokenProvider.tsx +++ b/src/renderer/src/context/TokenProvider.tsx @@ -8,9 +8,12 @@ import { jwtDecode } from 'jwt-decode'; import { useGetGlobal, useGlobal } from '../context/useGlobal'; import { useUpdateOrbitToken } from '../crud'; import { LocalKey, logError, Severity, useInterval } from '../utils'; +import { removeOrbitRemote } from '../utils/removeOrbitRemote'; import { isElectron } from '../../api-variable'; import { useProjectDefaults } from '../crud/useProjectDefaults'; import { MainAPI } from '@model/main-api'; +import envVariables from '../auth/auth0-variables.json'; +const { apiIdentifier } = envVariables; const ipc = window?.api as MainAPI; const Expires = 0; // Set to 7110 to test 1:30 token @@ -20,6 +23,7 @@ const initState = { profile: undefined as User | undefined, expiresAt: 0 as number | null, email_verified: false as boolean | undefined, + authSessionCleared: false as boolean, logout: () => {}, invalidateOnlineSession: () => {}, resetExpiresAt: () => {}, @@ -55,6 +59,7 @@ function TokenProvider(props: IProps) { const [modalOpen, setModalOpen] = React.useState(false); const [secondsToExpire, setSecondsToExpire] = React.useState(0); const [errorReporter] = useGlobal('errorReporter'); + const [coordinator] = useGlobal('coordinator'); const updateOrbitToken = useUpdateOrbitToken(); const view = React.useRef(''); const { getLocalDefault } = useProjectDefaults(); @@ -67,6 +72,9 @@ function TokenProvider(props: IProps) { const expiresAtRef = useRef(null); const skipAuthRestoreRef = useRef(false); const getGlobal = useGetGlobal(); + const webTokenOptions = { + authorizationParams: { audience: apiIdentifier }, + }; const setAuthSession = (profile: User | undefined, accessToken: string) => { skipAuthRestoreRef.current = false; if (accessToken) { @@ -81,8 +89,10 @@ function TokenProvider(props: IProps) { profile, expiresAt: expiresAtRef.current, email_verified: profile?.email_verified, + authSessionCleared: false, })); localStorage.setItem(LocalKey.loggedIn, 'true'); + updateOrbitToken(accessToken); }; React.useEffect(() => { @@ -93,9 +103,8 @@ function TokenProvider(props: IProps) { } if (skipAuthRestoreRef.current) return; if (user) { - getAccessTokenSilently() + getAccessTokenSilently(webTokenOptions) .then((token) => { - updateOrbitToken(token); setAuthSession(user, token); }) .catch(() => { @@ -116,10 +125,12 @@ function TokenProvider(props: IProps) { profile: undefined, expiresAt: -1, email_verified: false, + authSessionCleared: true, })); }; const invalidateOnlineSession = () => { + void removeOrbitRemote(coordinator); logout(); localStorage.removeItem(LocalKey.goingOnline); if (isElectron) { @@ -143,7 +154,6 @@ function TokenProvider(props: IProps) { .then(async () => { const myUser = await ipc?.getProfile(); const myToken = (await ipc?.getToken()) as string; - updateOrbitToken(myToken); setAuthSession(myUser, myToken); }) .catch((e: Error) => { @@ -153,17 +163,11 @@ function TokenProvider(props: IProps) { logError(Severity.error, errorReporter, e); }); } else { - getAccessTokenSilently() + getAccessTokenSilently(webTokenOptions) .then((token) => { - updateOrbitToken(token); setAuthSession(user, token); }) .catch((e: any) => { - console.log( - 'token error', - JSON.stringify(e), - window?.location?.pathname - ); if (e.error === 'login_required' && window?.location?.pathname) { localStorage.setItem(LocalKey.deeplink, window?.location?.pathname); } @@ -175,7 +179,8 @@ function TokenProvider(props: IProps) { }; React.useEffect(() => { - if (!getGlobal('offline')) { + // Web token restore is handled by the auth0Effect above. + if (!getGlobal('offline') && isElectron) { if (localStorage.getItem(LocalKey.loggedIn) === 'true') { resetExpiresAt(); } diff --git a/src/renderer/src/crud/useUpdateOrbitToken.ts b/src/renderer/src/crud/useUpdateOrbitToken.ts index 1a62250c..f7899053 100644 --- a/src/renderer/src/crud/useUpdateOrbitToken.ts +++ b/src/renderer/src/crud/useUpdateOrbitToken.ts @@ -1,18 +1,18 @@ /* eslint-disable react-hooks/immutability */ import { useGlobal } from '../context/useGlobal'; import JSONAPISource from '@orbit/jsonapi'; +import { syncRemoteAuthHeaders } from '../utils/syncRemoteAuthHeaders'; export const useUpdateOrbitToken = () => { const [coordinator] = useGlobal('coordinator'); - const remote = coordinator?.getSource('remote') as JSONAPISource; + const [fingerprint] = useGlobal('fingerprint'); return (myToken: string) => { - // Update the token in the orbit request processor - const reqHeaders = remote?.requestProcessor.defaultFetchSettings?.headers; - if (reqHeaders) - remote.requestProcessor.defaultFetchSettings.headers = { - ...reqHeaders, - Authorization: 'Bearer ' + myToken, - }; + const remote = coordinator?.getSource('remote') as JSONAPISource; + const datachangeremote = coordinator?.getSource( + 'datachanges' + ) as JSONAPISource; + syncRemoteAuthHeaders(remote, myToken, fingerprint || ''); + syncRemoteAuthHeaders(datachangeremote, myToken, fingerprint || ''); }; }; diff --git a/src/renderer/src/hoc/PrivateRoute.tsx b/src/renderer/src/hoc/PrivateRoute.tsx index 228005ca..8ddb1bee 100644 --- a/src/renderer/src/hoc/PrivateRoute.tsx +++ b/src/renderer/src/hoc/PrivateRoute.tsx @@ -16,8 +16,9 @@ export function PrivateRoute({ el }: IProps) { if (!pathname?.endsWith('null') && pathname !== '/loading') localStorage.setItem(localUserKey(LocalKey.url), pathname); - if (!offline && authenticated && !authenticated()) + if (!offline && authenticated && !authenticated()) { navigate('/', { state: { from: pathname } }); + } return el; } diff --git a/src/renderer/src/routes/Access.tsx b/src/renderer/src/routes/Access.tsx index ebdf140e..1d35ecf2 100644 --- a/src/renderer/src/routes/Access.tsx +++ b/src/renderer/src/routes/Access.tsx @@ -113,7 +113,7 @@ export function Access() { const [, setCompleted] = useGlobal('progress'); const getGlobal = useGetGlobal(); const tokenCtx = useContext(TokenContext); - const { logout, accessToken, expiresAt } = tokenCtx.state; + const { logout, accessToken, expiresAt, authSessionCleared } = tokenCtx.state; const [importOpen, setImportOpen] = useState(false); const [view, setView] = useState(''); const [curUser, setCurUser] = useState(); @@ -289,9 +289,14 @@ export function Access() { : ({ login_hint: 'signUp' } as RedirectLoginOptions); loginWithRedirect(opts); } - } else if (!accessToken && expiresAt === -1 && !isLoading) { - // Auth0 says logged in but local session was cleared (e.g. 401) — not - // the normal post-callback window before getAccessTokenSilently resolves. + } else if ( + authSessionCleared && + !accessToken && + expiresAt === -1 && + !isLoading + ) { + // Auth0 still authenticated after local session was cleared (e.g. 401). + // Not the post-callback window before getAccessTokenSilently resolves. tokenCtx.state.invalidateOnlineSession(); } } diff --git a/src/renderer/src/routes/Loading.tsx b/src/renderer/src/routes/Loading.tsx index e09b988f..874e477d 100644 --- a/src/renderer/src/routes/Loading.tsx +++ b/src/renderer/src/routes/Loading.tsx @@ -28,6 +28,7 @@ import { forceLogin, useMyNavigate, useDataChanges, + isOrbitQueueCancelled, orbitErr, } from '../utils'; import { @@ -67,6 +68,7 @@ export function Loading() { const orbitFetchResults = useSelector( (state: IState) => state.orbit.fetchResults ); + const orbitErrorMsg = useSelector((state: IState) => state.orbit.message); const t: IMainStrings = useSelector(mainSelector, shallowEqual); const dispatch = useDispatch(); const fetchLocalization = () => dispatch(action.fetchLocalization() as any); @@ -115,8 +117,10 @@ export function Loading() { const [view, setView] = useState(''); const [inviteError, setInviteError] = useState(''); const mounted = useRef(0); + const authFailureHandled = useRef(false); const getGlobal = useGetGlobal(); const forceDataChanges = useDataChanges(); + const { expiresAt } = tokenCtx.state; const handleAuthFailure = () => { forceLogin(); @@ -125,8 +129,10 @@ export function Loading() { setRemoteBusy(false); setUser(''); setOrbitRetries(OrbitNetworkErrorRetries); - void remote?.requestQueue?.clear?.(); - tokenCtx.state.invalidateOnlineSession(); + const alreadyInvalidated = tokenCtx.state.expiresAt === -1; + if (!alreadyInvalidated) { + tokenCtx.state.invalidateOnlineSession(); + } if (isElectron) { navigate('/access/online'); } @@ -202,8 +208,8 @@ export function Loading() { }; useEffect(() => { if (mounted.current > 0) return; + if (!offline && (!accessToken || !authenticated())) return; mounted.current += 1; - if (!offline && !authenticated()) return; if (!offline) { const decodedToken = jwtDecode(accessToken || '') as IToken; setExpireAt(decodedToken.exp); @@ -227,7 +233,7 @@ export function Loading() { forceDataChanges, }); /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, []); + }, [accessToken, offline]); useEffect(() => { if (orbitFetchResults) { @@ -377,6 +383,7 @@ export function Loading() { handleAuthFailure(); return; } + if (isOrbitQueueCancelled(ex)) return; if (apiEx?.response?.status != null) { doOrbitError(apiEx); } else { @@ -418,11 +425,30 @@ export function Loading() { }; useEffect(() => { - if (!offline && !authenticated()) { + if (orbitErrorMsg) { + setRemoteBusy(false); + setCompleted(0); + } + }, [orbitErrorMsg, setCompleted, setRemoteBusy]); + + useEffect(() => { + if ((expiresAt ?? 0) > 0) { + authFailureHandled.current = false; + } + }, [expiresAt]); + + useEffect(() => { + if ( + !offline && + expiresAt === -1 && + !authFailureHandled.current && + isElectron + ) { + authFailureHandled.current = true; handleAuthFailure(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [offline, accessToken, profile]); + }, [offline, expiresAt]); if (view !== '') navigate(view); @@ -431,9 +457,16 @@ export function Loading() { + {orbitErrorMsg && ( + + + {t.logout} + + + )} {loadComplete && inviteError && ( diff --git a/src/renderer/src/store/orbit/actions.tsx b/src/renderer/src/store/orbit/actions.tsx index ce1dc122..b627835a 100644 --- a/src/renderer/src/store/orbit/actions.tsx +++ b/src/renderer/src/store/orbit/actions.tsx @@ -9,7 +9,7 @@ import { } from './types'; import Coordinator from '@orbit/coordinator'; import { Sources } from '../../Sources'; -import { Severity, orbitErr } from '../../utils'; +import { Severity, isOrbitQueueCancelled, orbitErr, getHttpStatus } from '../../utils'; import { OfflineProject, Plan, VProject } from '../../model'; import { ITokenContext } from '../../context/TokenProvider'; import { AlertSeverity } from '../../hoc/SnackBar'; @@ -103,8 +103,10 @@ export const fetchOrbitData = dispatch({ type: FETCH_ORBIT_DATA, payload: fr }); }) .catch((ex: unknown) => { + const status = getHttpStatus(ex); + if (isOrbitQueueCancelled(ex)) return; + if (status === 401) return; const apiEx = ex as IApiError; - if (apiEx?.response?.status === 401) return; if (apiEx?.response?.status != null) { dispatch(orbitError(apiEx)); } else { diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts index 58cf2cd2..21f8ef8a 100644 --- a/src/renderer/src/utils/index.ts +++ b/src/renderer/src/utils/index.ts @@ -8,6 +8,7 @@ export * from './exitElectronApp'; export * from './fileJson'; export * from './forceLogin'; export * from './infoMsg'; +export * from './removeOrbitRemote'; export * from './insertAtCursor'; export * from './launch'; export * from './linuxProgPath'; @@ -61,6 +62,7 @@ export * from './eqSet'; export * from './cleanClipboard'; export * from './isVisual'; export * from './restoreScroll'; +export * from './syncRemoteAuthHeaders'; export * from './startEnd'; export * from './rememberCurrentPassage'; export * from './onlyUnique'; diff --git a/src/renderer/src/utils/infoMsg.ts b/src/renderer/src/utils/infoMsg.ts index 64cf3e38..1fc3048b 100644 --- a/src/renderer/src/utils/infoMsg.ts +++ b/src/renderer/src/utils/infoMsg.ts @@ -22,6 +22,18 @@ const orbitMsg = (err: Error | IApiError | null, info: string): string => ((err.data as { errors: { detail: string }[] }).errors?.[0]?.detail || '') : info + (err ? ': ' + err.message : ''); +export const isOrbitQueueCancelled = (ex: unknown): boolean => + ex instanceof Error && + /TaskQueue#clear|Processing cancelled/i.test(ex.message); + +export const getHttpStatus = (ex: unknown): number | undefined => { + if (ex && typeof ex === 'object' && 'response' in ex) { + const status = (ex as { response?: { status?: number } }).response?.status; + if (typeof status === 'number') return status; + } + return undefined; +}; + export const orbitErr = ( err: Error | IApiError | null, info: string diff --git a/src/renderer/src/utils/removeOrbitRemote.ts b/src/renderer/src/utils/removeOrbitRemote.ts new file mode 100644 index 00000000..ce6d350b --- /dev/null +++ b/src/renderer/src/utils/removeOrbitRemote.ts @@ -0,0 +1,30 @@ +import { LogLevel } from '@orbit/coordinator'; +import Coordinator from '@orbit/coordinator'; + +const remoteStrategies = [ + 'remote-query-fail', + 'remote-update-fail', + 'remote-request', + 'remote-update', + 'remote-sync', +] as const; + +export async function removeOrbitRemote( + coordinator: Coordinator | undefined, + reactivate = true +): Promise { + if (!coordinator?.sourceNames.includes('remote')) return; + await coordinator.deactivate(); + for (const name of remoteStrategies) { + if (coordinator.strategyNames.includes(name)) { + coordinator.removeStrategy(name); + } + } + coordinator.removeSource('remote'); + if (coordinator.sourceNames.includes('datachanges')) { + coordinator.removeSource('datachanges'); + } + if (reactivate) { + await coordinator.activate({ logLevel: LogLevel.Warnings }); + } +} diff --git a/src/renderer/src/utils/syncRemoteAuthHeaders.ts b/src/renderer/src/utils/syncRemoteAuthHeaders.ts new file mode 100644 index 00000000..551210dc --- /dev/null +++ b/src/renderer/src/utils/syncRemoteAuthHeaders.ts @@ -0,0 +1,16 @@ +import JSONAPISource from '@orbit/jsonapi'; + +export const syncRemoteAuthHeaders = ( + remote: JSONAPISource | undefined, + accessToken: string, + fingerprint: string +): void => { + if (!remote?.requestProcessor) return; + const settings = remote.requestProcessor.defaultFetchSettings ?? {}; + settings.headers = { + ...settings.headers, + Authorization: 'Bearer ' + accessToken, + 'X-FP': fingerprint, + }; + remote.requestProcessor.defaultFetchSettings = settings; +};