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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user