Repository navigation
Description
What Happened?
The API issues access tokens that expire after one hour (auth.service.ts#L75). There is no refresh endpoint. apps/web does not read the token's exp claim (auth.slice.ts#L9) and has no 401 handler. The token is kept only in memory, so a page reload goes to the login page.
After the token expires, a user who is filling in an instrument can lose the data in two ways.
1. The user comes back to the tab. This is the more severe case. The user does nothing to cause it.
- TanStack Query refetches the instrument bundle when the tab becomes visible again, because
useInstrumentBundleuses the defaultstaleTime: 0(useInstrumentBundle.ts#L7-L19). - The API returns 401.
- Queries set
throwOnError: true(react-query.ts#L12-L13). TanStack Query v5 sends a failed background refetch to the error boundary, even when the query already has data. AppErrorComponentreplaces the render page withErrorPage(AppErrorComponent.tsx#L36-L38). It shows "401 - Unauthorized" and tells the user to send an error report to the platform administrator (ErrorPage.tsx#L21-L35).- The form unmounts, and the entered data is lost. The only way back is "Reload Page" and a new login, which also clears
currentSession.
2. The user submits the instrument. The submit handler is a raw axios.post (render/$id.tsx#L41), and FormContent calls it with void (FormContent.tsx#L60). The 401 becomes an unhandled promise rejection. It does not go to the error boundary. The user sees only the generic "HTTP Request Failed / 401" toast (axios.ts#L200-L207). The result for the entered data depends on the instrument kind:
| Kind | Result |
|---|---|
| FORM | The values stay on the screen. The user cannot sign in again without a page reload, so the values are lost. |
FORM with resetButton |
The values are cleared at once. The libui Form resets after onSubmit returns, and the voided promise returns at once. |
| INTERACTIVE | The task has ended, and there is no way to submit its result again. The result is lost. |
| FILE | The upload status is FAILED. The selected files stay. |
What Did You Expect?
The app should warn the user before the session expires. It should let the user sign in again without losing the instrument that is open. At minimum, an expired session should send the user to the login page with a clear "session expired" message.
Operating System
Any
Browser (if applicable)
Chrome, Firefox, Safari, Edge
Steps to Reproduce
Use pnpm dev:test, not pnpm dev. When NODE_ENV is development, the API ignores token expiry (jwt.strategy.ts#L18).
- Log in and open an instrument for a subject. Enter some data.
- Wait more than one hour, or sign a short-lived token for testing.
- Switch to another tab, then come back. The error page replaces the form.
For the second case, do steps 1 and 2, then submit the instrument. A 401 toast shows, and the record is not saved.
Anything Else?
Possible approaches, from smallest to largest:
- Add a 401 response interceptor in
services/axios.tsthat goes to/auth/loginwith a "session expired" message.- Skip requests that set
meta.disableDefaultAuth, for example the login request. - Do not import the router in
axios.ts, because that makes a circular import. Usewindow.location.assign. - The login page has no "session expired" message now. Add one.
- This approach still loses the data that is on the screen.
- Skip requests that set
- Decode
expin the auth slice. Warn the user a few minutes before expiry and offer re-authentication in a dialog, so the current route and form state stay mounted.jwt-decodeis already a dependency ofapps/web, andapps/playgroundalready checksexp(playground auth.slice.ts#L17).- The login request must move out of
routes/auth/login.tsxinto a shared function, so that the dialog can call it.
- Add a refresh-token flow. This conflicts with the memory-only token design, so it needs a decision from the maintainers.
Approaches 2 and 3 must keep in-progress instrument data across re-authentication.
Separately, a failed background refetch should not unmount a page that already has data. One option is throwOnError: (error, query) => query.state.data === undefined in services/react-query.ts. apps/web/AGENTS.md makes throwOnError: true a rule, so this change also needs a decision from the maintainers. See also #1538.
Tests. A unit test for the chosen expiry handling. An e2e test that uses a forged expired token, or page.route to return 401, for both cases: a refetch when the tab becomes visible on the render page, and a submit. Do not wait one hour in a test.
Activity
[-]Session expires after one hour without warning; a submission after expiry fails with a generic 401[/-][+]Session expires after one hour without warning, and in-progress instrument data is lost[/+]joshunrau commentedon Oct 26, 2026
Partly fixed on
main(d86740e). 7fd9025 (#1538) gave the bundle querystaleTime: Infinity, so a non-admin who comes back to the tab keeps the form. An admin still loses it, and the submit case is unchanged for everyone (void onSubmitis now atFormContent.tsx:61,expiresIn: '1h'atauth.service.ts:84).Why admins still lose the form
For a user who can
manage all,useSwitchableGroupsrunsgroupsQueryOptions()through a plainuseQuery(apps/web/src/hooks/useSwitchableGroups.ts:16), and that query sets nostaleTime(apps/web/src/hooks/useGroupsQuery.ts:7-15).Layout,NavbarandSidebarall mount it throughuseIsGroupSwitcherVisible. The refetch on focus gets the 401 and throws from the_applayout, so the error page replaces the whole layout, render page included. The #1538 e2e test runs as the defaultGROUP_MANAGERand does not catch this.Corrections to the approaches
NavigationBlockerenablesbeforeunloadon the content step (packages/react-core/src/components/InstrumentRenderer/InstrumentRendererContainer.tsx:107-116,apps/web/src/components/NavigationBlocker.tsx:9). Awindow.location.assignfrom the interceptor raises the browser's "Leave site?" prompt. If the user cancels, they stay on a page where every request returns 401.meta.disableDefaultAuth(apps/web/src/routes/auth/login.tsx:19-21). ItsvalidateStatusaccepts 401, so the error handler never sees it. Key the interceptor on whether the request carried a bearer token.login(). It restorespreferredGroupIdonly if the token's groups contain it (apps/web/src/store/slices/auth.slice.ts:32-45), so an admin working in a group they are not a member of falls back togroups[0].currentGroupIdis part of the bundle query key, so the bundle refetches,render/$id.tsx:103-105renders<Spinner />, and the renderer unmounts with the data.Recommendation
Hold 401s in the axios layer and replay them after the user signs in again, in the same shape as the transient retry (
apps/web/src/services/axios.ts:140-161).isSessionExpiredin the store and wait (useAppStore.subscribe) untilaccessTokendiffers from the token it was sent with. Thenreturn axios(requestConfig); the request interceptor (axios.ts:117-119) attaches the new token. Concurrent 401s share one wait and show no toast. Replaying a write is safe, unlike in the transient case: on an authenticated route the 401 comes from the globalJwtAuthGuard(apps/api/src/auth/auth.module.ts:30-31) before the handler runs, and only the public login handler throws one.reauthenticate(accessToken)inauth.slice.ts. It replacesaccessTokenandcurrentUserand keepscurrentSessionand the current group id. Take the group object from the new token when its id is there, so changed group settings apply; keep the old object otherwise.SessionExpiredDialogin__root.tsx, besideConnectivityBannerand outside the_apperror boundary. It asks only for the password, with the username fixed tocurrentUser.username, so no other user can take overcurrentSession, and it offers Sign out. MoveloginRequestintosrc/hooks/withmeta.disableDefaultAuth: true. If the account is now archived, disabled ormustResetPassword, sign out. Strings go throught()withen,esandfr.A held query neither errors nor unmounts, so this fixes both cases with
throwOnError: trueand theapps/web/AGENTS.mdrule unchanged.Separately, in its own PR, I would change the query default to
(_error, query) => query.state.data === undefined.useSuspenseQueryalready overrides the client default with exactly that function (defaultThrowOnError,@tanstack/react-query5.104.0), sotrueonly reaches plainuseQuery. For a non-admin today, a failed refetch leaves/datahub/subjectson screen with a toast but replaces/dashboard(useSummaryQuery,useInstrumentInfoQuery) with the error page. Update the AGENTS.md bullet andservices/__tests__/react-query.test.tswith it, and leave mutations attrue.Open question
A warning before expiry, or approach 3, only helps if an open tab may renew its token without a password. Is one hour an absolute cap or an idle timeout? If idle, an API route that re-signs a still-valid token while the user is active is enough, and tokens stay in memory.
Tests
services/__tests__/axios.test.ts: a held 401 is replayed with the new token; concurrent 401s share one wait; a request without a token is not held; a held 401 shows no toast.store/slices/__tests__/auth.slice.test.ts:reauthenticatekeepscurrentSessionand the current group.routes/auth/__tests__/login.test.tsxafter the move.testing/src/specs/session-expiry.spec.ts. After the page loads,page.route('**/v1/**')returns 401 only whenAuthorizationcarries the injected token (window.__PLAYWRIGHT_ACCESS_TOKEN__), so the old token "expires" and the new one works.roleAccountexposes no password, so seed the user withapi.createUser()and pass its credentials toauthenticateAs; forADMIN, useADMIN.password./dashboard, and on the render page asADMIN, hide and show the tab as the Instrument bundle refetches on window focus although the bundle for an ID does not change #1538 test does (setTabVisibility). The dialog shows,error-pagedoes not, and the entered values survive sign-in.#1534 changes what the token carries; whichever lands second adjusts
reauthenticate.Triage note written with Claude Code. Code references point at
main@d86740e.