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.
+
+ )}
+