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 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-22 09:16:31 +01:00
parent 2ebacae0e0
commit c7d336ec59
4 changed files with 63 additions and 5 deletions

View File

@@ -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'}
</button>
)}
{cancelOrder.isError && (
<span className="text-xs text-[var(--color-red)]">
{cancelOrder.error.message}
</span>
)}
</td>
</tr>
);

View File

@@ -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<T>(
path: string,
@@ -9,6 +22,12 @@ async function apiFetch<T>(
): Promise<T> {
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<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> ?? {}),
@@ -24,10 +43,9 @@ async function apiFetch<T>(
});
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.');
}

View File

@@ -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();

View File

@@ -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<string | null>(null);
const [loading, setLoading] = useState(false);
const { login, authenticated } = useAuth();
@@ -50,6 +59,12 @@ export function LoginPage() {
Sign in to access the dashboard
</p>
{sessionExpired && (
<p className="text-xs text-[var(--color-yellow,#ca8a04)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded px-3 py-2 mb-4 text-center">
Your session has expired. Please sign in again.
</p>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs text-[var(--color-text-secondary)] mb-1">