Revitalize backend: working cloud saves, logout, and account UX

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>
This commit is contained in:
2026-04-28 19:32:03 -04:00
parent 2912d760cb
commit 2a6629af79
16 changed files with 732 additions and 23 deletions
+48 -1
View File
@@ -1,7 +1,16 @@
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;
@@ -10,6 +19,8 @@ interface AuthGateState {
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;
@@ -22,6 +33,7 @@ export function useAuthGate(): AuthGateState {
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 () => {
@@ -52,9 +64,30 @@ export function useAuthGate(): AuthGateState {
}
const payload = getTokenPayload();
setRegistered(checkRegistered());
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);
}, []);
@@ -66,6 +99,18 @@ export function useAuthGate(): AuthGateState {
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();
@@ -89,6 +134,8 @@ export function useAuthGate(): AuthGateState {
registered,
isAdmin: admin,
config,
cloudSave,
loadCloudSave,
setRegistered: handleSetRegistered,
setNeedsPasswordReset: handleSetPasswordReset,
retry,
+20 -3
View File
@@ -3,22 +3,27 @@ import { useGameStore } from '@/store';
import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api';
import { AUTO_SAVE_INTERVAL_TICKS } from '@token-empire/shared';
const MAX_CONSECUTIVE_FAILURES = 3;
export function useCloudSave() {
const tickCount = useGameStore((s) => s.meta.tickCount);
const companyName = useGameStore((s) => s.meta.companyName);
const lastSaveTick = useRef(0);
const failureCount = useRef(0);
useEffect(() => {
if (!companyName) return;
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS * 5) return;
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS) return;
const token = getAuthToken();
if (!token) return;
if (failureCount.current >= MAX_CONSECUTIVE_FAILURES) return;
lastSaveTick.current = tickCount;
const state = useGameStore.getState();
const { activePage, notifications, ...gameState } = state;
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
api.saves.put({
companyName: state.meta.companyName,
@@ -26,7 +31,19 @@ export function useCloudSave() {
gameData: gameState,
tickCount: state.meta.tickCount,
era: state.meta.currentEra,
}).catch(() => {});
}).then(() => {
failureCount.current = 0;
}).catch(() => {
failureCount.current++;
if (failureCount.current === MAX_CONSECUTIVE_FAILURES) {
useGameStore.getState().addNotification({
title: 'Cloud Save Failed',
message: 'Unable to save to cloud. Your progress is still saved locally.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
});
}, [tickCount, companyName]);
}