feat: add session timeout warning before JWT expiry (FE-20)#919
feat: add session timeout warning before JWT expiry (FE-20)#919Chris0Jeky merged 11 commits intomainfrom
Conversation
Adds a refreshToken() call to the auth API module that POSTs to /auth/refresh. Used by the session timeout warning to attempt silent session extension.
Composable that monitors the session store token and schedules a warning 5 minutes before JWT expiry. Features: - Reactive countdown in seconds - "Extend Session" action (attempts token refresh, graceful fallback) - Token-level dedup prevents warning spam after dismiss - Skips demo mode and unauthenticated states - Timer cleanup on unmount and token change
Renders a fixed-position warning banner with countdown timer, Extend Session button, and Dismiss action. Uses the useSessionTimeout composable for all state and logic.
Adds the session timeout warning component alongside the existing ToastContainer so it renders globally on all routes.
19 tests covering: no-auth, demo mode, far-future expiry, warning timer firing, immediate warning, countdown ticking, expiry reset, dismiss, dedup per token, new-token after dismiss, logout reset, demo-mode reset, extend success/failure/idempotency, expired token, teardown cleanup, and token reschedule.
teardown() now stops the reactive watcher in addition to clearing timers and state, preventing a memory leak when the composable is used outside a component context (e.g. tests) or when teardown is called manually.
There was a problem hiding this comment.
Code Review
This pull request implements a session timeout warning system to notify users before their JWT expires. It adds a SessionTimeoutWarning component to the main application shell, a useSessionTimeout composable for managing expiry timers and countdowns, and a refreshToken method to the authentication API. Review feedback highlights the need to refactor the session extension logic by centralizing it within the session store to prevent state synchronization issues and code duplication. Additionally, it is recommended to use existing type definitions instead of verbose inline types for better maintainability.
| const { useSessionStore: getStore } = await import('../store/sessionStore') | ||
| const store = getStore() | ||
| // Use internal setSession logic: update store state with new token | ||
| store.token = response.token | ||
| store.expiresAt = new Date( | ||
| (parseJwtPayload(response.token)?.exp ?? 0) * 1000, | ||
| ).toISOString() | ||
|
|
||
| // Persist the new token | ||
| const tokenStorage = await import('../utils/tokenStorage') | ||
| tokenStorage.setToken(response.token) |
There was a problem hiding this comment.
This block manually implements session update logic that should be centralized in the store.
Issues:
- Redundancy:
useSessionStoreis already available assession(initialized on line 48). The dynamic import and re-initialization ofstoreare unnecessary. - Incompleteness: It only updates
tokenandexpiresAt, ignoring other user metadata returned inAuthResponseand skipping thetokenStorage.setSessioncall. This will cause the session metadata to be out of sync or lost upon page refresh. - Duplication: The expiry calculation logic is duplicated from the
getTokenExpiryIsoutility.
Recommendation: Move this logic into a refreshToken action within sessionStore.ts. The composable should simply call await session.refreshToken(), ensuring all state updates and persistence logic are handled centrally and correctly.
| * @param deps Injectable dependencies for testing | ||
| */ | ||
| export function useSessionTimeout(deps?: { | ||
| refreshToken?: () => Promise<{ token: string; user: { id: string; username: string; email: string; defaultRole: number; isActive: boolean; createdAt: string; updatedAt: string } }> |
Adversarial Self-ReviewIssues found and fixed
Reviewed and acceptable
Test coverage assessment19 tests cover all composable paths. The component ( Remaining work (not in scope)
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b4f16b5d32
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!refreshToken) { | ||
| // No refresh capability — dynamic import to avoid circular dependency | ||
| const { authApi } = await import('../api/authApi') | ||
| const response = await authApi.refreshToken() |
There was a problem hiding this comment.
Bypass global 401 redirect for refresh attempt
This refreshToken call uses the shared http client, and that client's 401 interceptor unconditionally clears session storage and redirects to /login on non-auth routes (src/api/http.ts, response interceptor). Because that redirect fires before this composable's catch, a 401 from /auth/refresh (for example, expired access token or refresh not available) will navigate away immediately instead of showing the intended fallback warning/toast flow, which can interrupt unsaved work.
Useful? React with 👍 / 👎.
| store.token = response.token | ||
| store.expiresAt = new Date( |
There was a problem hiding this comment.
Update full session state after token refresh
On refresh success, the code mutates only token and expiresAt directly, so the user payload returned by AuthResponse is ignored and session metadata (username/email/defaultRole snapshot) is not synchronized through the store's canonical session update path. If user profile fields change between login and refresh, the app keeps stale identity/role data until a later restore/hydration cycle.
Useful? React with 👍 / 👎.
Adversarial Review -- PR #919 (Session Timeout Warning)I have read the full diff, all 4 bot inline comments, and done my own independent review. Summary of findings below. CRITICAL: No backend
|
Allow callers (e.g. token refresh) to suppress the automatic redirect to /login on 401, so they can handle the error locally with a toast instead of interrupting the user's work.
Set skipAuth401 and skipRetry flags on the refresh request so a failed refresh does not trigger the global redirect-to-login or retry loop.
Centralizes token refresh through the canonical setSession path, ensuring all session state (token, userId, username, email, defaultRole, expiresAt) and persistence are updated correctly.
… type Replace manual store state mutation with the centralized refreshSession action that goes through setSession. Use the imported AuthResponse type instead of verbose inline type for the deps parameter.
The parent div already has aria-live="assertive"; a nested polite region on the countdown span can cause screen readers to double-announce updates.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5670ca33d7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| role="alert" | ||
| aria-live="assertive" |
There was a problem hiding this comment.
Use non-assertive live region for ticking expiry timer
This warning container is marked as role="alert" with aria-live="assertive", but its text updates every second via the countdown, so screen readers can repeatedly interrupt users for up to several minutes while the banner is visible. In practice this creates a noisy, disruptive accessibility regression for keyboard/screen-reader users on any session nearing expiry; the countdown text should not be announced assertively on every tick.
Useful? React with 👍 / 👎.
Summary
useSessionTimeoutcomposable that monitors JWT expiry and shows a persistent warning banner 5 minutes before the token expires/auth/refresh, graceful fallback toast on failure), and "Dismiss" buttonSessionTimeoutWarning.vuecomponent wired intoApp.vuefor global visibilityauthApi.refreshToken()added (backend endpoint not yet implemented; composable handles failure gracefully)Closes #861
Test plan
npm run typecheck)npm run lint) -- 0 new warningsnpm run build)