Add backend health check, fetch timeouts, stale token cleanup, and error screen

Frontend now checks /health before starting auth flow. Shows a clear
"Cannot Connect to Server" screen with retry button when backend is
unreachable. Stale non-JWT tokens in localStorage are detected and
cleared automatically. All API calls have a 10s timeout via AbortController.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 19:55:50 -04:00
parent 066c3310ff
commit 2ab097ec8a
4 changed files with 117 additions and 43 deletions
+44 -27
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from 'react';
import { api, getTokenPayload, isRegistered as checkRegistered, needsPasswordReset as checkNeedsReset, setAuthToken } from '@/lib/api';
import { useState, useCallback } from 'react';
import { api, getTokenPayload, isRegistered as checkRegistered, needsPasswordReset as checkNeedsReset, validateStoredToken } from '@/lib/api';
import { ensureAuth } from './useCloudSave';
interface AuthGateState {
loading: boolean;
backendError: string | null;
needsInvite: boolean;
needsPasswordReset: boolean;
registered: boolean;
@@ -11,46 +12,60 @@ interface AuthGateState {
config: { requireInvite: boolean; userInvitations: number } | null;
setRegistered: (value: boolean) => void;
setNeedsPasswordReset: (value: boolean) => void;
retry: () => void;
}
export function useAuthGate(): AuthGateState {
const [config, setConfig] = useState<{ requireInvite: boolean; userInvitations: number } | null>(null);
const [loading, setLoading] = useState(true);
const [backendError, setBackendError] = useState<string | null>(null);
const [registered, setRegistered] = useState(false);
const [passwordReset, setPasswordReset] = useState(false);
const [admin, setAdmin] = useState(false);
const [initCount, setInitCount] = useState(0);
useEffect(() => {
let cancelled = false;
const init = useCallback(async () => {
setLoading(true);
setBackendError(null);
async function init() {
try {
const [cfg] = await Promise.all([
api.config.get(),
ensureAuth(),
]);
validateStoredToken();
if (cancelled) return;
setConfig(cfg);
const payload = getTokenPayload();
const reg = checkRegistered();
setRegistered(reg);
setPasswordReset(checkNeedsReset());
setAdmin(payload?.role === 'admin');
} catch {
// Config fetch failed — allow game to load (fail open)
setConfig({ requireInvite: false, userInvitations: 0 });
} finally {
if (!cancelled) setLoading(false);
}
try {
await api.health();
} catch (e) {
setBackendError(e instanceof Error ? e.message : 'Cannot connect to server');
setLoading(false);
return;
}
init();
return () => { cancelled = true; };
try {
const cfg = await api.config.get();
setConfig(cfg);
} catch {
setConfig({ requireInvite: false, userInvitations: 0 });
}
try {
await ensureAuth();
} catch {
// auth failed — will show as unregistered
}
const payload = getTokenPayload();
setRegistered(checkRegistered());
setPasswordReset(checkNeedsReset());
setAdmin(payload?.role === 'admin');
setLoading(false);
}, []);
// Run init on mount and on retry
useState(() => { init(); });
const retry = useCallback(() => {
setInitCount(c => c + 1);
init();
}, [init]);
const handleSetRegistered = useCallback((value: boolean) => {
setRegistered(value);
const payload = getTokenPayload();
@@ -68,6 +83,7 @@ export function useAuthGate(): AuthGateState {
return {
loading,
backendError,
needsInvite,
needsPasswordReset: passwordReset,
registered,
@@ -75,5 +91,6 @@ export function useAuthGate(): AuthGateState {
config,
setRegistered: handleSetRegistered,
setNeedsPasswordReset: handleSetPasswordReset,
retry,
};
}