From c7d336ec5966c16e3fa52feaef9ba07465bbb072 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sun, 22 Feb 2026 09:16:31 +0100 Subject: [PATCH] safety(web-dashboard): token expiry detection, session-expired banner, mutation error feedback - Add isTokenExpired() / getTokenExpiry() JWT helpers to auth.ts - Pre-flight token expiry check in apiFetch (60s margin) - Graceful 401 handling: set session_expired flag before redirect - LoginPage shows "session expired" banner when flag is set - OrdersTable cancel button: add pending state + inline error display Co-Authored-By: Claude Opus 4.6 --- .../src/components/trading/OrdersTable.tsx | 7 ++++- web-dashboard/src/lib/api.ts | 26 ++++++++++++++++--- web-dashboard/src/lib/auth.ts | 20 ++++++++++++++ web-dashboard/src/pages/LoginPage.tsx | 15 +++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/web-dashboard/src/components/trading/OrdersTable.tsx b/web-dashboard/src/components/trading/OrdersTable.tsx index 1e0aef2d5..8c11a8df7 100644 --- a/web-dashboard/src/components/trading/OrdersTable.tsx +++ b/web-dashboard/src/components/trading/OrdersTable.tsx @@ -98,9 +98,14 @@ function OrderRow({ order }: { order: Order }) { disabled={cancelOrder.isPending} className="px-2 py-0.5 text-xs rounded bg-[var(--color-red)]/20 text-[var(--color-red)] hover:bg-[var(--color-red)]/30 disabled:opacity-50" > - Cancel + {cancelOrder.isPending ? 'Cancelling...' : 'Cancel'} )} + {cancelOrder.isError && ( + + {cancelOrder.error.message} + + )} ); diff --git a/web-dashboard/src/lib/api.ts b/web-dashboard/src/lib/api.ts index 4aca026cb..c089d7dd0 100644 --- a/web-dashboard/src/lib/api.ts +++ b/web-dashboard/src/lib/api.ts @@ -1,7 +1,20 @@ -import { clearTokens } from './auth'; +import { clearTokens, isTokenExpired, SESSION_EXPIRED_KEY } from './auth'; const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; +/** Margin in seconds before actual expiry to treat the token as expired */ +const EXPIRY_MARGIN_SECONDS = 60; + +/** + * Redirect to login with a session-expired flag so the LoginPage can + * display a user-friendly message instead of a silent redirect. + */ +function redirectToLogin(): void { + sessionStorage.setItem(SESSION_EXPIRED_KEY, '1'); + clearTokens(); + window.location.href = '/login'; +} + /** Fetch wrapper that injects JWT Authorization header and handles 401 auto-logout */ async function apiFetch( path: string, @@ -9,6 +22,12 @@ async function apiFetch( ): Promise { const token = localStorage.getItem('foxhunt_token'); + // Pre-flight: detect expired (or about-to-expire) tokens before making the request + if (token && isTokenExpired(token, EXPIRY_MARGIN_SECONDS)) { + redirectToLogin(); + throw new Error('Session expired. Please log in again.'); + } + const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record ?? {}), @@ -24,10 +43,9 @@ async function apiFetch( }); if (!res.ok) { - // Auto-logout on 401 (expired/invalid token) + // Server rejected the token — redirect gracefully if (res.status === 401) { - clearTokens(); - window.location.href = '/login'; + redirectToLogin(); throw new Error('Session expired. Please log in again.'); } diff --git a/web-dashboard/src/lib/auth.ts b/web-dashboard/src/lib/auth.ts index ab60723ca..a4ce42574 100644 --- a/web-dashboard/src/lib/auth.ts +++ b/web-dashboard/src/lib/auth.ts @@ -33,6 +33,26 @@ export function isAuthenticated(): boolean { } } +/** Get the expiry timestamp (seconds since epoch) from a JWT, or null if undecodable */ +export function getTokenExpiry(token: string): number | null { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return typeof payload.exp === 'number' ? payload.exp : null; + } catch { + return null; + } +} + +/** Check whether a JWT token is expired or within `marginSeconds` of expiry */ +export function isTokenExpired(token: string, marginSeconds = 0): boolean { + const exp = getTokenExpiry(token); + if (exp === null) return true; // treat undecodable tokens as expired + return exp - marginSeconds <= Date.now() / 1000; +} + +/** Session-expired flag key for sessionStorage */ +export const SESSION_EXPIRED_KEY = 'session_expired'; + /** Decode JWT claims without verification (for display purposes) */ export function getTokenClaims(): { sub: string; roles: string[] } | null { const token = getToken(); diff --git a/web-dashboard/src/pages/LoginPage.tsx b/web-dashboard/src/pages/LoginPage.tsx index 5dcb804d8..81b664706 100644 --- a/web-dashboard/src/pages/LoginPage.tsx +++ b/web-dashboard/src/pages/LoginPage.tsx @@ -2,15 +2,24 @@ import { useState, type FormEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; import { apiPost } from '../lib/api'; +import { SESSION_EXPIRED_KEY } from '../lib/auth'; interface LoginResponse { token: string; refresh_token?: string; } +/** Read and clear the session-expired flag set by the API client on 401 / token expiry */ +function consumeSessionExpiredFlag(): boolean { + const expired = sessionStorage.getItem(SESSION_EXPIRED_KEY) === '1'; + if (expired) sessionStorage.removeItem(SESSION_EXPIRED_KEY); + return expired; +} + export function LoginPage() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [sessionExpired] = useState(consumeSessionExpiredFlag); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const { login, authenticated } = useAuth(); @@ -50,6 +59,12 @@ export function LoginPage() { Sign in to access the dashboard

+ {sessionExpired && ( +

+ Your session has expired. Please sign in again. +

+ )} +