diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index fc21f0b..f7978ee 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -7,7 +7,7 @@ import { InviteGateScreen } from '@/components/game/InviteGateScreen';
import { useGameLoop } from '@/hooks/useGameLoop';
import { useAuthGate } from '@/hooks/useAuthGate';
import { TICK_INTERVAL_MS } from '@ai-tycoon/shared';
-import { Sparkles } from 'lucide-react';
+import { Sparkles, RefreshCw, WifiOff } from 'lucide-react';
function LoadingScreen() {
return (
@@ -25,8 +25,35 @@ function LoadingScreen() {
);
}
+function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () => void }) {
+ return (
+
+
+
+
+
+ AI Tycoon
+
+
+
+
+
+
Cannot Connect to Server
+
{error}
+
+
+
+
+ );
+}
+
export function App() {
- const { loading: authLoading, needsInvite, needsPasswordReset, setRegistered, setNeedsPasswordReset } = useAuthGate();
+ const { loading: authLoading, backendError, needsInvite, needsPasswordReset, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
const companyName = useGameStore((s) => s.meta.companyName);
const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp);
const [catchUpTicks, setCatchUpTicks] = useState(null);
@@ -43,12 +70,16 @@ export function App() {
}
}, [companyName, lastTickTimestamp, catchUpDone]);
- useGameLoop(!catchUpDone || authLoading || needsInvite || needsPasswordReset);
+ useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset);
if (authLoading) {
return ;
}
+ if (backendError) {
+ return ;
+ }
+
if (needsInvite || needsPasswordReset) {
return (
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(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,
};
}
diff --git a/apps/web/src/hooks/useCloudSave.ts b/apps/web/src/hooks/useCloudSave.ts
index efebe08..bf0a38b 100644
--- a/apps/web/src/hooks/useCloudSave.ts
+++ b/apps/web/src/hooks/useCloudSave.ts
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useGameStore } from '@/store';
-import { api, getAuthToken, setAuthToken } from '@/lib/api';
+import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api';
import { AUTO_SAVE_INTERVAL_TICKS } from '@ai-tycoon/shared';
export function useCloudSave() {
@@ -31,8 +31,11 @@ export function useCloudSave() {
}
export async function ensureAuth(): Promise {
- let token = getAuthToken();
- if (token) return token;
+ const token = getAuthToken();
+ if (token) {
+ if (decodeTokenPayload(token)) return token;
+ clearAuthToken();
+ }
try {
const result = await api.auth.anonymous();
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 926352c..d1c7bdb 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -63,30 +63,53 @@ export function needsPasswordReset(): boolean {
return payload?.mustResetPassword === true;
}
-async function request(path: string, options: RequestInit = {}): Promise {
+async function request(path: string, options: RequestInit & { timeoutMs?: number } = {}): Promise {
+ const { timeoutMs = 10_000, ...fetchOptions } = options;
+
const headers: Record = {
'Content-Type': 'application/json',
- ...(options.headers as Record),
+ ...(fetchOptions.headers as Record),
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
- const res = await fetch(`${API_BASE}${path}`, {
- ...options,
- headers,
- });
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
- if (!res.ok) {
- const body = await res.json().catch(() => ({ error: 'Unknown error' }));
- throw new Error(body.error || `HTTP ${res.status}`);
+ try {
+ const res = await fetch(`${API_BASE}${path}`, {
+ ...fetchOptions,
+ headers,
+ signal: controller.signal,
+ });
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({ error: 'Unknown error' }));
+ throw new Error(body.error || `HTTP ${res.status}`);
+ }
+
+ return res.json();
+ } catch (e) {
+ if (e instanceof DOMException && e.name === 'AbortError') {
+ throw new Error('Request timed out — server may be unreachable');
+ }
+ throw e;
+ } finally {
+ clearTimeout(timeout);
}
+}
- return res.json();
+export function validateStoredToken(): void {
+ const token = getAuthToken();
+ if (token && !decodeTokenPayload(token)) {
+ clearAuthToken();
+ }
}
export const api = {
+ health: () => request<{ status: string }>('/health', { timeoutMs: 5_000 }),
auth: {
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
login: (login: string, password: string) =>