2a6629af79
Cloud saves were fully built but never wired up — useCloudSave() hook was never called, no load-from-cloud flow existed, and there was no way to continue a saved game. Logout was completely missing (no endpoint, no UI). Accounts felt like a gate behind the invite wall rather than real accounts. Backend: add tokenVersion to users for server-side token invalidation, POST /auth/logout bumps it to revoke all JWTs, GET /auth/me returns profile, GET /saves/latest returns most recent save with full gameData. All createToken calls now include tokenVersion. Auth middleware rejects tokens with stale tokenVersion. Frontend: wire up useCloudSave() in App (auto-saves every 60 ticks with error handling), fetch cloud save on startup for registered users, show "Continue Your Game" card on NewGameScreen, add Log Out button with confirmation in Settings, show username in sidebar, 401 interceptor clears auth and reloads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
144 lines
3.8 KiB
TypeScript
144 lines
3.8 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { api, getTokenPayload, isRegistered as checkRegistered, needsPasswordReset as checkNeedsReset, validateStoredToken } from '@/lib/api';
|
|
import { useGameStore } from '@/store';
|
|
import { ensureAuth } from './useCloudSave';
|
|
|
|
export interface CloudSaveInfo {
|
|
id: string;
|
|
companyName: string;
|
|
era: string;
|
|
tickCount: number;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface AuthGateState {
|
|
loading: boolean;
|
|
backendError: string | null;
|
|
needsInvite: boolean;
|
|
needsPasswordReset: boolean;
|
|
registered: boolean;
|
|
isAdmin: boolean;
|
|
config: { requireInvite: boolean; userInvitations: number } | null;
|
|
cloudSave: CloudSaveInfo | null;
|
|
loadCloudSave: () => Promise<void>;
|
|
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 [cloudSave, setCloudSave] = useState<CloudSaveInfo | null>(null);
|
|
const [initCount, setInitCount] = useState(0);
|
|
|
|
const init = useCallback(async () => {
|
|
setLoading(true);
|
|
setBackendError(null);
|
|
|
|
validateStoredToken();
|
|
|
|
try {
|
|
await api.health();
|
|
} catch (e) {
|
|
setBackendError(e instanceof Error ? e.message : 'Cannot connect to server');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
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();
|
|
const isReg = checkRegistered();
|
|
setRegistered(isReg);
|
|
setPasswordReset(checkNeedsReset());
|
|
setAdmin(payload?.role === 'admin');
|
|
|
|
if (isReg) {
|
|
try {
|
|
const { save } = await api.saves.latest();
|
|
if (save && save.tickCount > 0) {
|
|
setCloudSave({
|
|
id: save.id,
|
|
companyName: save.companyName,
|
|
era: save.era,
|
|
tickCount: save.tickCount,
|
|
updatedAt: save.updatedAt,
|
|
});
|
|
} else {
|
|
setCloudSave(null);
|
|
}
|
|
} catch {
|
|
setCloudSave(null);
|
|
}
|
|
}
|
|
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
// Run init on mount and on retry
|
|
useState(() => { init(); });
|
|
|
|
const retry = useCallback(() => {
|
|
setInitCount(c => c + 1);
|
|
init();
|
|
}, [init]);
|
|
|
|
const loadCloudSave = useCallback(async () => {
|
|
try {
|
|
const { save } = await api.saves.latest();
|
|
if (save?.gameData) {
|
|
const gameData = save.gameData as Record<string, unknown>;
|
|
useGameStore.setState(gameData);
|
|
}
|
|
} catch {
|
|
// Fall through to new game if cloud load fails
|
|
}
|
|
}, []);
|
|
|
|
const handleSetRegistered = useCallback((value: boolean) => {
|
|
setRegistered(value);
|
|
const payload = getTokenPayload();
|
|
if (payload) {
|
|
setAdmin(payload.role === 'admin');
|
|
setPasswordReset(payload.mustResetPassword);
|
|
}
|
|
}, []);
|
|
|
|
const handleSetPasswordReset = useCallback((value: boolean) => {
|
|
setPasswordReset(value);
|
|
}, []);
|
|
|
|
const needsInvite = !!(config?.requireInvite && !registered);
|
|
|
|
return {
|
|
loading,
|
|
backendError,
|
|
needsInvite,
|
|
needsPasswordReset: passwordReset,
|
|
registered,
|
|
isAdmin: admin,
|
|
config,
|
|
cloudSave,
|
|
loadCloudSave,
|
|
setRegistered: handleSetRegistered,
|
|
setNeedsPasswordReset: handleSetPasswordReset,
|
|
retry,
|
|
};
|
|
}
|