From 3161ef7d91f504811d9cdc6baf2c02cd6cad2359 Mon Sep 17 00:00:00 2001 From: cory Date: Fri, 5 Jun 2026 15:34:33 +0000 Subject: [PATCH] feat: show session timeout modal and redirect on 401 responses --- src/components/auth/SessionTimeoutModal.tsx | 49 +++++++++++++++---- src/lib/api.ts | 54 +++++++++++++++++---- 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/components/auth/SessionTimeoutModal.tsx b/src/components/auth/SessionTimeoutModal.tsx index e654b03..c41f212 100644 --- a/src/components/auth/SessionTimeoutModal.tsx +++ b/src/components/auth/SessionTimeoutModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { Dialog, @@ -10,23 +10,51 @@ import { import { Button } from '@/components/ui/button'; import { tokenManager } from '@/lib/api'; +const AUTO_REDIRECT_SECONDS = 5; + export default function SessionTimeoutModal() { const [open, setOpen] = useState(false); + const [secondsLeft, setSecondsLeft] = useState(AUTO_REDIRECT_SECONDS); const navigate = useNavigate(); + const timerRef = useRef>(); - const handleOpenChange = useCallback((isOpen: boolean) => { - if (!isOpen) { - tokenManager.clearToken(); - navigate('/login', { replace: true }); - } + const redirectToLogin = useCallback(() => { + tokenManager.clearToken(); + navigate('/login', { replace: true }); }, [navigate]); useEffect(() => { - const onSessionExpired = () => setOpen(true); + const onSessionExpired = () => { + setOpen(true); + setSecondsLeft(AUTO_REDIRECT_SECONDS); + }; window.addEventListener('session:expired', onSessionExpired); return () => window.removeEventListener('session:expired', onSessionExpired); }, []); + useEffect(() => { + if (open) { + timerRef.current = setInterval(() => { + setSecondsLeft((prev) => { + if (prev <= 1) { + clearInterval(timerRef.current); + redirectToLogin(); + return 0; + } + return prev - 1; + }); + }, 1000); + } + return () => clearInterval(timerRef.current); + }, [open, redirectToLogin]); + + const handleOpenChange = useCallback((isOpen: boolean) => { + if (!isOpen) { + clearInterval(timerRef.current); + redirectToLogin(); + } + }, [redirectToLogin]); + return ( -
-
diff --git a/src/lib/api.ts b/src/lib/api.ts index 7291256..2a0f882 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -421,27 +421,42 @@ async function request( cache: 'no-store', }); + // Handle HTTP 401 before parsing JSON — catches non-JSON responses and + // unknown body shapes that would otherwise bypass the session-expired logic + if (response.status === 401) { + let errorType = 'UNAUTHORIZED'; + try { + const errBody = await response.json(); + errorType = errBody.error?.type || 'UNAUTHORIZED'; + } catch { + // Non-JSON body (HTML error page, plain text, etc.) — use default + } + + if (clearTokenOn401 !== false) { + tokenManager.clearToken(); + window.dispatchEvent(new CustomEvent('session:expired')); + if (import.meta.env.DEV) { + console.log(`[API] Token cleared on 401 (type: ${errorType}, endpoint: ${endpoint})`); + } + } + throw new ApiError('Unauthorized', 401, errorType, {}); + } + const json: ApiResponse = await response.json(); if (!json.success) { const errorType = json.error?.type || 'UNKNOWN_ERROR'; - // Handle 401 token clearing based on configuration + // Handle 401 in JSON body (backstop for servers that return 200 with code:401) if (json.code === 401) { - const shouldClearToken = - clearTokenOn401 === true || - (clearTokenOn401 === 'auto' && SESSION_INVALID_ERROR_TYPES.includes(errorType)); - - if (shouldClearToken) { + if (clearTokenOn401 !== false) { tokenManager.clearToken(); - // Dispatch event so the UI can show a session timeout modal window.dispatchEvent(new CustomEvent('session:expired')); if (import.meta.env.DEV) { console.log(`[API] Token cleared on 401 (type: ${errorType}, endpoint: ${endpoint})`); } - } else if (import.meta.env.DEV) { - console.log(`[API] 401 received but token preserved (type: ${errorType}, endpoint: ${endpoint})`); } + throw new ApiError(json.message || 'Unauthorized', 401, errorType, json.error?.details || {}); } // Handle 403 authorization errors @@ -772,6 +787,11 @@ export const api = { const res = await fetch(`${config.api.baseUrl}/superadmin/users/${userId}/audit-logs/export${qs}`, { headers: { 'Authorization': `Bearer ${token}` }, }); + if (res.status === 401) { + tokenManager.clearToken(); + window.dispatchEvent(new CustomEvent('session:expired')); + throw new ApiError('Unauthorized', 401, 'UNAUTHORIZED'); + } if (!res.ok) throw new ApiError('Export failed', res.status, 'EXPORT_ERROR'); const blob = await res.blob(); const url = URL.createObjectURL(blob); @@ -865,6 +885,11 @@ export const api = { }, }); if (!response.ok) { + if (response.status === 401) { + tokenManager.clearToken(); + window.dispatchEvent(new CustomEvent('session:expired')); + throw new ApiError('Unauthorized', 401, 'UNAUTHORIZED'); + } const error = await response.json(); throw new ApiError( error.message || 'Failed to begin registration', @@ -893,6 +918,11 @@ export const api = { body: JSON.stringify({ email }), }); if (!response.ok) { + if (response.status === 401) { + tokenManager.clearToken(); + window.dispatchEvent(new CustomEvent('session:expired')); + throw new ApiError('Unauthorized', 401, 'UNAUTHORIZED'); + } const error = await response.json(); throw new ApiError( error.message || 'No passkeys found for this account', @@ -916,6 +946,12 @@ export const api = { body: JSON.stringify(assertion), }); + if (response.status === 401) { + tokenManager.clearToken(); + window.dispatchEvent(new CustomEvent('session:expired')); + throw new ApiError('Unauthorized', 401, 'UNAUTHORIZED'); + } + const json: ApiResponse = await response.json(); if (!json.success) {