Skip to content

Session expires after one hour without warning, and in-progress instrument data is lost #1535

Open

Description

@gdevenyi

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.

  1. TanStack Query refetches the instrument bundle when the tab becomes visible again, because useInstrumentBundle uses the default staleTime: 0 (useInstrumentBundle.ts#L7-L19).
  2. The API returns 401.
  3. 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.
  4. AppErrorComponent replaces the render page with ErrorPage (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).
  5. 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).

  1. Log in and open an instrument for a subject. Enter some data.
  2. Wait more than one hour, or sign a short-lived token for testing.
  3. 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:

  1. Add a 401 response interceptor in services/axios.ts that goes to /auth/login with 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. Use window.location.assign.
    • The login page has no "session expired" message now. Add one.
    • This approach still loses the data that is on the screen.
  2. Decode exp in 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-decode is already a dependency of apps/web, and apps/playground already checks exp (playground auth.slice.ts#L17).
    • The login request must move out of routes/auth/login.tsx into a shared function, so that the dialog can call it.
  3. 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

  1. added
    BugType: existing behavior is wrong
    on Sep 11, 2026
  2. changed the title [-]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[/+] on Sep 11, 2026
  3. added
    Priority: HighSignificant or frequently hit user impact; target the next release or two
    Difficulty: HighMore than a day, or touches several workspaces
    Area: Webapps/web (clinician-facing React SPA)
    Needs DecisionWell-defined, but blocked on a maintainer decision about direction or design
    on Sep 27, 2026
  4. joshunrau commented on Oct 26, 2026

    @joshunrau
    Collaborator

    Partly fixed on main (d86740e). 7fd9025 (#1538) gave the bundle query staleTime: 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 onSubmit is now at FormContent.tsx:61, expiresIn: '1h' at auth.service.ts:84).

    Why admins still lose the form

    For a user who can manage all, useSwitchableGroups runs groupsQueryOptions() through a plain useQuery (apps/web/src/hooks/useSwitchableGroups.ts:16), and that query sets no staleTime (apps/web/src/hooks/useGroupsQuery.ts:7-15). Layout, Navbar and Sidebar all mount it through useIsGroupSwitcherVisible. The refetch on focus gets the 401 and throws from the _app layout, so the error page replaces the whole layout, render page included. The #1538 e2e test runs as the default GROUP_MANAGER and does not catch this.

    Corrections to the approaches

    • Approach 1. Since 82ed5d4, the render page's NavigationBlocker enables beforeunload on the content step (packages/react-core/src/components/InstrumentRenderer/InstrumentRendererContainer.tsx:107-116, apps/web/src/components/NavigationBlocker.tsx:9). A window.location.assign from the interceptor raises the browser's "Leave site?" prompt. If the user cancels, they stay on a page where every request returns 401.
    • The login request does not set meta.disableDefaultAuth (apps/web/src/routes/auth/login.tsx:19-21). Its validateStatus accepts 401, so the error handler never sees it. Key the interceptor on whether the request carried a bearer token.
    • Approach 2 cannot reuse the store's login(). It restores preferredGroupId only 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 to groups[0]. currentGroupId is part of the bundle query key, so the bundle refetches, render/$id.tsx:103-105 renders <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).

    1. Response interceptor. When a request that carried a token gets a 401, set isSessionExpired in the store and wait (useAppStore.subscribe) until accessToken differs from the token it was sent with. Then return 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 global JwtAuthGuard (apps/api/src/auth/auth.module.ts:30-31) before the handler runs, and only the public login handler throws one.
    2. reauthenticate(accessToken) in auth.slice.ts. It replaces accessToken and currentUser and keeps currentSession and 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.
    3. A SessionExpiredDialog in __root.tsx, beside ConnectivityBanner and outside the _app error boundary. It asks only for the password, with the username fixed to currentUser.username, so no other user can take over currentSession, and it offers Sign out. Move loginRequest into src/hooks/ with meta.disableDefaultAuth: true. If the account is now archived, disabled or mustResetPassword, sign out. Strings go through t() with en, es and fr.

    A held query neither errors nor unmounts, so this fixes both cases with throwOnError: true and the apps/web/AGENTS.md rule unchanged.

    Separately, in its own PR, I would change the query default to (_error, query) => query.state.data === undefined. useSuspenseQuery already overrides the client default with exactly that function (defaultThrowOnError, @tanstack/react-query 5.104.0), so true only reaches plain useQuery. For a non-admin today, a failed refetch leaves /datahub/subjects on screen with a toast but replaces /dashboard (useSummaryQuery, useInstrumentInfoQuery) with the error page. Update the AGENTS.md bullet and services/__tests__/react-query.test.ts with it, and leave mutations at true.

    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: reauthenticate keeps currentSession and the current group.
    • A unit test for the dialog, and routes/auth/__tests__/login.test.tsx after the move.
    • A new testing/src/specs/session-expiry.spec.ts. After the page loads, page.route('**/v1/**') returns 401 only when Authorization carries the injected token (window.__PLAYWRIGHT_ACCESS_TOKEN__), so the old token "expires" and the new one works. roleAccount exposes no password, so seed the user with api.createUser() and pass its credentials to authenticateAs; for ADMIN, use ADMIN.password.

    #1534 changes what the token carries; whichever lands second adjusts reauthenticate.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Webapps/web (clinician-facing React SPA)BugType: existing behavior is wrongDifficulty: HighMore than a day, or touches several workspacesNeeds DecisionWell-defined, but blocked on a maintainer decision about direction or designPriority: HighSignificant or frequently hit user impact; target the next release or two

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions