From 444d3d2cd63ce365cdf7486f073969fcf0a28736 Mon Sep 17 00:00:00 2001 From: Shree Chaturvedi Date: Mon, 3 Aug 2026 17:21:39 -0400 Subject: [PATCH] fix(auth): only warn session expiry after true idle Reset activity timers on pointer/keyboard/scroll/API traffic. Emit automl:api-activity from apiFetch on success. Toast only after 5m idle. Add regression tests for the idle gate. Fixes #181 --- .../__tests__/useTokenRefreshTimer.test.ts | 14 ++++ frontend/src/hooks/useTokenRefreshTimer.ts | 65 ++++++++++++++++--- frontend/src/lib/api/client.ts | 8 +++ 3 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 frontend/src/hooks/__tests__/useTokenRefreshTimer.test.ts diff --git a/frontend/src/hooks/__tests__/useTokenRefreshTimer.test.ts b/frontend/src/hooks/__tests__/useTokenRefreshTimer.test.ts new file mode 100644 index 00000000..08e6faa9 --- /dev/null +++ b/frontend/src/hooks/__tests__/useTokenRefreshTimer.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { shouldWarnSessionExpiring, SESSION_IDLE_WARN_MS } from '../useTokenRefreshTimer'; + +describe('shouldWarnSessionExpiring', () => { + it('does not warn while recently active', () => { + const now = 1_000_000; + expect(shouldWarnSessionExpiring(now - 1000, now, SESSION_IDLE_WARN_MS)).toBe(false); + }); + + it('warns after true idle', () => { + const now = 1_000_000; + expect(shouldWarnSessionExpiring(now - SESSION_IDLE_WARN_MS - 1, now, SESSION_IDLE_WARN_MS)).toBe(true); + }); +}); diff --git a/frontend/src/hooks/useTokenRefreshTimer.ts b/frontend/src/hooks/useTokenRefreshTimer.ts index 6fbf212e..5f071442 100644 --- a/frontend/src/hooks/useTokenRefreshTimer.ts +++ b/frontend/src/hooks/useTokenRefreshTimer.ts @@ -1,19 +1,56 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useCallback } from 'react'; import { useAuthStore } from '@/stores/authStore'; import { refreshAccessToken } from '@/lib/api/client'; import { decodeJwtPayload } from '@/lib/auth/jwt'; import { toast } from 'sonner'; const RETRY_DELAY_MS = 30_000; +/** Idle threshold before a failed refresh surfaces the "Session expiring" toast. */ +export const SESSION_IDLE_WARN_MS = 5 * 60_000; /** * Proactively refreshes the access token at 80% of its TTL so users never * experience a session expiry during active use. + * + * Activity (pointer / keyboard / scroll / touch / visibility / API traffic) + * resets the idle clock. The "Session expiring soon" warning only fires + * after true idle — not while the user is actively interacting. */ export function useTokenRefreshTimer() { const accessToken = useAuthStore((s) => s.accessToken); const refreshToken = useAuthStore((s) => s.refreshToken); const timerRef = useRef | undefined>(undefined); + const lastActivityRef = useRef(Date.now()); + + const markActivity = useCallback(() => { + lastActivityRef.current = Date.now(); + }, []); + + useEffect(() => { + if (typeof window === 'undefined') return; + const events: Array = [ + 'pointerdown', + 'keydown', + 'mousemove', + 'scroll', + 'touchstart', + ]; + for (const evt of events) { + window.addEventListener(evt, markActivity, { passive: true }); + } + window.addEventListener('automl:api-activity', markActivity as EventListener); + const onVis = () => { + if (document.visibilityState === 'visible') markActivity(); + }; + document.addEventListener('visibilitychange', onVis); + return () => { + for (const evt of events) { + window.removeEventListener(evt, markActivity); + } + window.removeEventListener('automl:api-activity', markActivity as EventListener); + document.removeEventListener('visibilitychange', onVis); + }; + }, [markActivity]); useEffect(() => { if (!accessToken || !refreshToken) return; @@ -25,7 +62,6 @@ export function useTokenRefreshTimer() { const ttl = payload.exp - nowSec; if (ttl <= 0) return; - // Refresh at 80% of remaining TTL const refreshInMs = Math.max(ttl * 0.8 * 1000, 5000); const scheduleRefresh = (delayMs: number, isRetry = false) => { @@ -34,16 +70,20 @@ export function useTokenRefreshTimer() { const currentRefresh = useAuthStore.getState().refreshToken; const newToken = await refreshAccessToken(currentRefresh); if (newToken) { - // Token refreshed — next cycle will schedule via the accessToken dep change + markActivity(); return; } if (!isRetry) { - // Retry once after 30s scheduleRefresh(RETRY_DELAY_MS, true); } else { - toast.warning('Session expiring soon — please save your work', { - duration: 8000, - }); + const idleFor = Date.now() - lastActivityRef.current; + if (idleFor >= SESSION_IDLE_WARN_MS) { + toast.warning('Session expiring soon — please save your work', { + duration: 8000, + }); + } + // Keep retrying quietly while the tab is active. + scheduleRefresh(RETRY_DELAY_MS, true); } }, delayMs); }; @@ -51,5 +91,14 @@ export function useTokenRefreshTimer() { scheduleRefresh(refreshInMs); return () => clearTimeout(timerRef.current); - }, [accessToken, refreshToken]); + }, [accessToken, refreshToken, markActivity]); +} + +/** Test helper: whether the idle warn should fire. */ +export function shouldWarnSessionExpiring( + lastActivityMs: number, + nowMs: number, + idleWarnMs = SESSION_IDLE_WARN_MS +): boolean { + return nowMs - lastActivityMs >= idleWarnMs; } diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 207e7db6..27a854d4 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -1,5 +1,13 @@ import { useAuthStore } from '@/stores/authStore'; +/** Notify session idle tracking that authenticated API traffic occurred. */ +function notifyApiActivity(): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('automl:api-activity')); + } +} + + const BASE_URL = (import.meta.env.VITE_API_BASE ?? 'http://localhost:4000/api').replace(/\/$/, ''); type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';