Merge pull request 'Revitalize backend: working cloud saves, logout, and account UX' (#13) from feature/auth-invites into main
CI / build-and-push (push) Successful in 59s
CI / build-and-push (push) Successful in 59s
Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
@@ -6,6 +6,7 @@ import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
|
||||
import { InviteGateScreen } from '@/components/game/InviteGateScreen';
|
||||
import { useGameLoop } from '@/hooks/useGameLoop';
|
||||
import { useAuthGate } from '@/hooks/useAuthGate';
|
||||
import { useCloudSave } from '@/hooks/useCloudSave';
|
||||
import { TICK_INTERVAL_MS } from '@token-empire/shared';
|
||||
import { Sparkles, RefreshCw, WifiOff } from 'lucide-react';
|
||||
|
||||
@@ -53,7 +54,7 @@ function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () =>
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
|
||||
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, loadCloudSave, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
|
||||
const companyName = useGameStore((s) => s.meta.companyName);
|
||||
const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp);
|
||||
const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null);
|
||||
@@ -71,6 +72,7 @@ export function App() {
|
||||
}, [companyName, lastTickTimestamp, catchUpDone]);
|
||||
|
||||
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset);
|
||||
useCloudSave();
|
||||
|
||||
if (authLoading) {
|
||||
return <LoadingScreen />;
|
||||
@@ -92,7 +94,7 @@ export function App() {
|
||||
}
|
||||
|
||||
if (!companyName) {
|
||||
return <NewGameScreen />;
|
||||
return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />;
|
||||
}
|
||||
|
||||
if (catchUpTicks !== null && !catchUpDone) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Sparkles, Cloud, Play } from 'lucide-react';
|
||||
import { useGameStore } from '@/store';
|
||||
import type { CloudSaveInfo } from '@/hooks/useAuthGate';
|
||||
|
||||
const SUGGESTED_NAMES = [
|
||||
'Nexus AI', 'Cortex Labs', 'Synapse Technologies',
|
||||
@@ -8,8 +9,32 @@ const SUGGESTED_NAMES = [
|
||||
'Neural Forge', 'DeepMind+', 'Cerebral Systems',
|
||||
];
|
||||
|
||||
export function NewGameScreen() {
|
||||
const ERA_LABELS: Record<string, string> = {
|
||||
startup: 'Startup',
|
||||
scaleup: 'Scale-Up',
|
||||
bigtech: 'Big Tech',
|
||||
agi: 'AGI',
|
||||
};
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cloudSave?: CloudSaveInfo | null;
|
||||
onContinue?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function NewGameScreen({ cloudSave, onContinue }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const startNewGame = useGameStore((s) => s.startNewGame);
|
||||
|
||||
const handleStart = () => {
|
||||
@@ -17,6 +42,16 @@ export function NewGameScreen() {
|
||||
startNewGame(companyName);
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!onContinue) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onContinue();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
||||
<div className="max-w-md w-full mx-4">
|
||||
@@ -32,7 +67,37 @@ export function NewGameScreen() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{cloudSave && onContinue && (
|
||||
<div className="bg-surface-900 border border-accent/30 rounded-xl p-6 mb-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-accent-light">
|
||||
<Cloud size={18} />
|
||||
<h3 className="font-semibold text-sm">Continue Your Game</h3>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-semibold text-surface-100">{cloudSave.companyName}</div>
|
||||
<div className="flex items-center gap-3 text-xs text-surface-400">
|
||||
<span className="px-2 py-0.5 rounded-full bg-surface-800 border border-surface-700">
|
||||
{ERA_LABELS[cloudSave.era] ?? cloudSave.era}
|
||||
</span>
|
||||
<span>Tick {cloudSave.tickCount.toLocaleString()}</span>
|
||||
<span>Saved {formatTimeAgo(cloudSave.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={loading}
|
||||
className="w-full inline-flex items-center justify-center gap-2 bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Play size={16} />
|
||||
{loading ? 'Loading...' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-6">
|
||||
{cloudSave && onContinue && (
|
||||
<div className="text-xs text-surface-500 uppercase tracking-wider font-medium">Or start fresh</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-2">
|
||||
Name your AI company
|
||||
@@ -44,7 +109,7 @@ export function NewGameScreen() {
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStart()}
|
||||
placeholder={SUGGESTED_NAMES[0]}
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-3 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
autoFocus
|
||||
autoFocus={!cloudSave}
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PanelLeftClose, PanelLeftOpen, Mail, UserPlus, Copy, Check,
|
||||
} from 'lucide-react';
|
||||
import { useGameStore, type ActivePage } from '@/store';
|
||||
import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, api } from '@/lib/api';
|
||||
import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, getTokenPayload, api } from '@/lib/api';
|
||||
|
||||
const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string; adminOnly?: boolean }[] = [
|
||||
{ page: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
@@ -166,6 +166,11 @@ export function Sidebar() {
|
||||
)}
|
||||
|
||||
<div className={`${collapsed ? 'px-2 py-3 text-center' : 'p-4'} border-t border-surface-700 text-xs text-surface-500`}>
|
||||
{!collapsed && (() => {
|
||||
const payload = getTokenPayload();
|
||||
const displayName = payload?.username || payload?.email || 'Guest';
|
||||
return <div className="truncate mb-1 text-surface-400">{displayName}</div>;
|
||||
})()}
|
||||
{collapsed ? 'v0.1' : 'Token Empire v0.1'}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export function getAuthToken() {
|
||||
export function clearAuthToken() {
|
||||
authToken = null;
|
||||
localStorage.removeItem('token-empire-auth-token');
|
||||
localStorage.removeItem('token-empire-refresh-token');
|
||||
}
|
||||
|
||||
export interface TokenPayload {
|
||||
@@ -63,6 +64,8 @@ export function needsPasswordReset(): boolean {
|
||||
return payload?.mustResetPassword === true;
|
||||
}
|
||||
|
||||
const AUTH_PATHS = ['/api/auth/anonymous', '/api/auth/login', '/api/auth/logout', '/api/health'];
|
||||
|
||||
async function request<T>(path: string, options: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
||||
const { timeoutMs = 10_000, ...fetchOptions } = options;
|
||||
|
||||
@@ -86,6 +89,11 @@ async function request<T>(path: string, options: RequestInit & { timeoutMs?: num
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 && authToken && !AUTH_PATHS.includes(path)) {
|
||||
clearAuthToken();
|
||||
localStorage.removeItem('token-empire-save');
|
||||
window.location.reload();
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
@@ -140,6 +148,10 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, currentPassword }),
|
||||
}),
|
||||
logout: () =>
|
||||
request<{ success: boolean }>('/api/auth/logout', { method: 'POST' }),
|
||||
me: () =>
|
||||
request<{ id: string; username: string | null; email: string | null; role: string }>('/api/auth/me'),
|
||||
},
|
||||
config: {
|
||||
get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'),
|
||||
@@ -164,6 +176,7 @@ export const api = {
|
||||
saves: {
|
||||
list: () => request<{ saves: Array<{ id: string; companyName: string; era: string; tickCount: number; updatedAt: string }> }>('/api/saves'),
|
||||
get: (id: string) => request<{ save: { id: string; gameData: unknown } }>(`/api/saves/${id}`),
|
||||
latest: () => request<{ save: { id: string; companyName: string; era: string; tickCount: number; updatedAt: string; gameData: unknown } | null }>('/api/saves/latest'),
|
||||
put: (data: { companyName: string; saveVersion: number; gameData: unknown; tickCount: number; era: string }) =>
|
||||
request<{ id: string }>('/api/saves', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => request<{ deleted: boolean }>(`/api/saves/${id}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Pencil, Check, X } from 'lucide-react';
|
||||
import { Pencil, Check, X, LogOut } from 'lucide-react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
||||
import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin } from '@/lib/api';
|
||||
import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin, clearAuthToken } from '@/lib/api';
|
||||
|
||||
export function SettingsPage() {
|
||||
const settings = useGameStore((s) => s.meta.settings);
|
||||
@@ -18,6 +18,8 @@ export function SettingsPage() {
|
||||
const [usernameError, setUsernameError] = useState('');
|
||||
const [usernameSaving, setUsernameSaving] = useState(false);
|
||||
|
||||
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||||
|
||||
const [editingEmail, setEditingEmail] = useState(false);
|
||||
const [emailValue, setEmailValue] = useState('');
|
||||
const [emailPassword, setEmailPassword] = useState('');
|
||||
@@ -207,6 +209,16 @@ export function SettingsPage() {
|
||||
) : (
|
||||
<div className="text-sm text-surface-400">Playing as guest.</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-surface-700">
|
||||
<button
|
||||
onClick={() => setShowLogoutConfirm(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm text-surface-300 hover:text-surface-100 transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
{registered ? 'Log Out' : 'Sign Out'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
|
||||
@@ -290,6 +302,24 @@ export function SettingsPage() {
|
||||
onCancel={() => setImportData(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLogoutConfirm && (
|
||||
<ConfirmModal
|
||||
title={registered ? 'Log Out' : 'Sign Out'}
|
||||
message={registered
|
||||
? 'You will be logged out. Your game progress is saved to the cloud and will be available when you log back in.'
|
||||
: 'You will be signed out. As a guest, your local progress will be lost. Consider registering first to save your progress.'}
|
||||
confirmLabel={registered ? 'Log Out' : 'Sign Out'}
|
||||
danger={!registered}
|
||||
onConfirm={async () => {
|
||||
try { await api.auth.logout(); } catch {}
|
||||
clearAuthToken();
|
||||
localStorage.removeItem('token-empire-save');
|
||||
window.location.reload();
|
||||
}}
|
||||
onCancel={() => setShowLogoutConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user