Skip to content

Commit 442fa35

Browse files
authored
feat: session expiry warning toast (#1565)
* feat: add session expiry warning toast before token refresh Display a warning toast 2 minutes before session expires, giving users a chance to extend their session via an action button. The warning is dismissed automatically when the refresh timer fires or when the component unmounts. * fix: deduplicate concurrent refresh calls via shared in-flight promise Move the in-flight guard from showSessionWarning's onClick into refreshToken() itself using a ref-held promise. Both the toast action and the background auto-refresh timer now share the same in-flight request, preventing a race where a timer-triggered refresh could race with a user-triggered one and log the user out on a transient 401. Also adds a test covering the button+timer race scenario. * fix: address CodeRabbit minor review comments - updateToken() now returns boolean to propagate whether the token was actually accepted (prevents false 'Session extended' on subdomain mismatch) - refreshToken() propagates updateToken's boolean instead of unconditionally returning true after a successful fetch - Add warningActionInFlightRef to showSessionWarning onClick to prevent duplicate toast chains when the user clicks rapidly (fetch is already deduped; this guards the .then() handler attachment) - Replace vi.runAllTimersAsync() with vi.advanceTimersByTimeAsync(0) in success test to avoid triggering newly-scheduled long-delay timers * fix: prevent stale refresh from reviving cleared session and keep CTA on failure - Add auth generation counter (authGenerationRef) to updateToken; any in-flight refresh captured a generation at call time and discards its result if the generation changed (logout, 401, or tenant mismatch cleared auth while the request was in flight) - Only dismiss the session warning toast when the background timer-triggered refresh actually succeeds; keeps the 'Extend session' CTA visible as a manual recovery path when refresh fails transiently - Update test: mock a successful fetch for the refresh-timer-dismisses-toast case; add new test asserting toast stays visible on 5xx failure * fix: reject already-expired tokens returned by the refresh endpoint If /api/auth/refresh returns a token whose exp is already in the past, the previous code would accept it as success and immediately re-enter the expired-token refresh path on the next render. Guard with isTokenExpired(parsed) before calling updateToken(). --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent f0899f3 commit 442fa35

2 files changed

Lines changed: 414 additions & 36 deletions

File tree

frontend/src/contexts/auth-context.tsx

Lines changed: 121 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
1+
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
2+
import { toast } from 'sonner'
23
import { getTenantSlugFromSubdomain } from '@/lib/tenant-utils'
34

45
export interface JWTClaims {
@@ -116,6 +117,12 @@ interface AuthProviderProps {
116117

117118
const SESSION_STORAGE_KEY = 'meridian_access_token'
118119

120+
/** How far before expiry (ms) to show the warning toast */
121+
export const SESSION_WARNING_BEFORE_EXPIRY_MS = 120_000 // 2 minutes
122+
123+
/** Stable toast ID so we can dismiss programmatically */
124+
const SESSION_WARNING_TOAST_ID = 'session-expiry-warning'
125+
119126
function restoreToken(initialToken?: string): { token: string | null; claims: JWTClaims | null } {
120127
// Prefer explicit initialToken over stored token
121128
const candidate = initialToken ?? sessionStorage.getItem(SESSION_STORAGE_KEY)
@@ -143,20 +150,32 @@ export function AuthProvider({ children, initialToken }: AuthProviderProps) {
143150
const [accessToken, setAccessToken] = useState<string | null>(restored.token)
144151
const [claims, setClaims] = useState<JWTClaims | null>(restored.claims)
145152

146-
const updateToken = useCallback((token: string | null) => {
153+
// Auth generation counter: incremented whenever auth is explicitly cleared
154+
// (logout, 401, tenant mismatch). Any in-flight refresh that started before
155+
// the last clear will see a generation mismatch and discard its result,
156+
// preventing stale responses from reviving a cleared session.
157+
const authGenerationRef = useRef(0)
158+
159+
const updateToken = useCallback((token: string | null, generation?: number): boolean => {
160+
// Discard response if auth was cleared after this refresh started.
161+
if (generation !== undefined && generation !== authGenerationRef.current) {
162+
return false
163+
}
147164
if (!token) {
165+
authGenerationRef.current += 1
148166
setAccessToken(null)
149167
setClaims(null)
150168
sessionStorage.removeItem(SESSION_STORAGE_KEY)
151-
return
169+
return false
152170
}
153171
const parsed = parseJWT(token)
154172
if (!parsed) {
155173
// Malformed token - clear both token and claims
174+
authGenerationRef.current += 1
156175
setAccessToken(null)
157176
setClaims(null)
158177
sessionStorage.removeItem(SESSION_STORAGE_KEY)
159-
return
178+
return false
160179
}
161180
// Validate that the token's tenantId matches the current subdomain tenant.
162181
// This prevents session bleeding across subdomains for all token paths
@@ -165,14 +184,16 @@ export function AuthProvider({ children, initialToken }: AuthProviderProps) {
165184
// See tenant-context.tsx where claims.tenantId is used directly as tenantSlug.
166185
const currentSlug = getTenantSlugFromSubdomain(window.location.hostname)
167186
if (currentSlug && parsed.tenantId && parsed.tenantId !== currentSlug) {
187+
authGenerationRef.current += 1
168188
setAccessToken(null)
169189
setClaims(null)
170190
sessionStorage.removeItem(SESSION_STORAGE_KEY)
171-
return
191+
return false
172192
}
173193
setAccessToken(token)
174194
setClaims(parsed)
175195
sessionStorage.setItem(SESSION_STORAGE_KEY, token)
196+
return true
176197
}, [])
177198

178199
const login = useCallback(
@@ -186,38 +207,86 @@ export function AuthProvider({ children, initialToken }: AuthProviderProps) {
186207
updateToken(null)
187208
}, [updateToken])
188209

189-
const refreshToken = useCallback(async (): Promise<boolean> => {
190-
try {
191-
const response = await fetch('/api/auth/refresh', {
192-
method: 'POST',
193-
credentials: 'include',
194-
headers: { 'Content-Type': 'application/json' },
195-
})
210+
// In-flight promise ref: shared across the toast action and the background
211+
// timer so concurrent callers (e.g. button click racing with the auto-refresh
212+
// timer) reuse the same request instead of issuing two.
213+
const refreshInFlightRef = useRef<Promise<boolean> | null>(null)
214+
215+
const refreshToken = useCallback((): Promise<boolean> => {
216+
if (refreshInFlightRef.current) return refreshInFlightRef.current
217+
218+
// Capture generation at call time; discard result if auth is cleared
219+
// while the request is in flight (e.g., user logs out mid-refresh).
220+
const callGeneration = authGenerationRef.current
221+
222+
const request = (async () => {
223+
try {
224+
const response = await fetch('/api/auth/refresh', {
225+
method: 'POST',
226+
credentials: 'include',
227+
headers: { 'Content-Type': 'application/json' },
228+
})
229+
230+
if (!response.ok) {
231+
// Only clear auth state on 401 Unauthorized.
232+
// Transient errors (5xx, network) should not log the user out.
233+
if (response.status === 401) {
234+
updateToken(null, callGeneration)
235+
}
236+
return false
237+
}
196238

197-
if (!response.ok) {
198-
// Only clear auth state on 401 Unauthorized.
199-
// Transient errors (5xx, network) should not log the user out.
200-
if (response.status === 401) {
201-
updateToken(null)
239+
const data = (await response.json()) as { accessToken: string }
240+
const parsed = parseJWT(data.accessToken)
241+
if (!parsed || isTokenExpired(parsed)) {
242+
// Server returned a malformed or already-expired token - treat as refresh failure
243+
return false
202244
}
245+
return updateToken(data.accessToken, callGeneration)
246+
} catch {
247+
// Network error - do not clear auth state (may be transient)
203248
return false
249+
} finally {
250+
refreshInFlightRef.current = null
204251
}
252+
})()
205253

206-
const data = (await response.json()) as { accessToken: string }
207-
const parsed = parseJWT(data.accessToken)
208-
if (!parsed) {
209-
// Server returned a malformed token - treat as refresh failure without clearing auth
210-
return false
211-
}
212-
updateToken(data.accessToken)
213-
return true
214-
} catch {
215-
// Network error - do not clear auth state (may be transient)
216-
return false
217-
}
254+
refreshInFlightRef.current = request
255+
return request
218256
}, [updateToken])
219257

220-
// Check token expiry on mount and set up refresh timer
258+
// Prevents duplicate toast handlers when the user clicks "Extend session"
259+
// multiple times while the same in-flight request is still pending.
260+
const warningActionInFlightRef = useRef(false)
261+
262+
// Show session expiry warning toast
263+
const showSessionWarning = useCallback(() => {
264+
toast.warning('Your session is about to expire.', {
265+
id: SESSION_WARNING_TOAST_ID,
266+
duration: Infinity,
267+
action: {
268+
label: 'Extend session',
269+
onClick: () => {
270+
if (warningActionInFlightRef.current) return
271+
warningActionInFlightRef.current = true
272+
void refreshToken()
273+
.then((ok) => {
274+
if (ok) {
275+
toast.dismiss(SESSION_WARNING_TOAST_ID)
276+
toast.success('Session extended.')
277+
} else {
278+
toast.error('Failed to extend session. Please refresh the page to log in again.')
279+
}
280+
})
281+
.finally(() => {
282+
warningActionInFlightRef.current = false
283+
})
284+
},
285+
},
286+
})
287+
}, [refreshToken])
288+
289+
// Check token expiry on mount and set up refresh + warning timers
221290
useEffect(() => {
222291
if (!claims || !accessToken) return
223292
if (isTokenExpired(claims)) {
@@ -227,16 +296,32 @@ export function AuthProvider({ children, initialToken }: AuthProviderProps) {
227296
return
228297
}
229298

230-
// Schedule refresh 60 seconds before expiry
231299
const expiresInMs = claims.exp * 1000 - Date.now()
232-
const refreshInMs = Math.max(expiresInMs - 60_000, 0)
233300

234-
const timer = setTimeout(() => {
235-
void refreshToken()
301+
// Schedule warning toast ~2 minutes before expiry
302+
const warningInMs = Math.max(expiresInMs - SESSION_WARNING_BEFORE_EXPIRY_MS, 0)
303+
const warningTimer = setTimeout(() => {
304+
showSessionWarning()
305+
}, warningInMs)
306+
307+
// Schedule refresh 60 seconds before expiry; dismiss the warning toast
308+
// only if the refresh succeeds so the CTA stays visible as a manual
309+
// recovery path on transient errors.
310+
const refreshInMs = Math.max(expiresInMs - 60_000, 0)
311+
const refreshTimer = setTimeout(() => {
312+
void refreshToken().then((ok) => {
313+
if (ok) {
314+
toast.dismiss(SESSION_WARNING_TOAST_ID)
315+
}
316+
})
236317
}, refreshInMs)
237318

238-
return () => clearTimeout(timer)
239-
}, [claims, accessToken, refreshToken])
319+
return () => {
320+
clearTimeout(warningTimer)
321+
clearTimeout(refreshTimer)
322+
toast.dismiss(SESSION_WARNING_TOAST_ID)
323+
}
324+
}, [claims, accessToken, refreshToken, showSessionWarning])
240325

241326
const lens = getUserLens(claims)
242327
const isAuthenticated = claims !== null && !isTokenExpired(claims)

0 commit comments

Comments
 (0)