diff --git a/apps/server/drizzle/0001_certain_aaron_stack.sql b/apps/server/drizzle/0001_certain_aaron_stack.sql new file mode 100644 index 0000000..2c3f697 --- /dev/null +++ b/apps/server/drizzle/0001_certain_aaron_stack.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "token_version" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0001_snapshot.json b/apps/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..d3ef106 --- /dev/null +++ b/apps/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,477 @@ +{ + "id": "9324fe22-280a-4276-ace3-820f55654ec7", + "prevId": "8cfe4136-b228-464d-bf2c-e4f2e8c73ce1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.achievements": { + "name": "achievements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "achievement_id": { + "name": "achievement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "achievements_user_id_idx": { + "name": "achievements_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "achievements_user_id_users_id_fk": { + "name": "achievements_user_id_users_id_fk", + "tableFrom": "achievements", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "used_by": { + "name": "used_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invitations_created_by_users_id_fk": { + "name": "invitations_created_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invitations_used_by_users_id_fk": { + "name": "invitations_used_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "used_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitations_code_unique": { + "name": "invitations_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leaderboard": { + "name": "leaderboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "era": { + "name": "era", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leaderboard_category_score_idx": { + "name": "leaderboard_category_score_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leaderboard_user_id_users_id_fk": { + "name": "leaderboard_user_id_users_id_fk", + "tableFrom": "leaderboard", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saves": { + "name": "saves", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "save_version": { + "name": "save_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "game_data": { + "name": "game_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "era": { + "name": "era", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'startup'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "saves_user_id_idx": { + "name": "saves_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saves_user_id_users_id_fk": { + "name": "saves_user_id_users_id_fk", + "tableFrom": "saves", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "anon_token": { + "name": "anon_token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "must_reset_password": { + "name": "must_reset_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_anon_token_unique": { + "name": "users_anon_token_unique", + "nullsNotDistinct": false, + "columns": [ + "anon_token" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index cfec2a1..6f7a038 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1777333216602, "tag": "0000_tearful_hedge_knight", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1777417629552, + "tag": "0001_certain_aaron_stack", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 10003f2..84b458d 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -8,6 +8,7 @@ export const users = pgTable('users', { passwordHash: text('password_hash'), role: text('role').notNull().default('user'), mustResetPassword: boolean('must_reset_password').notNull().default(false), + tokenVersion: integer('token_version').notNull().default(0), createdAt: timestamp('created_at').defaultNow().notNull(), lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(), }); diff --git a/apps/server/src/lib/jwt.ts b/apps/server/src/lib/jwt.ts index 695ab65..6de731c 100644 --- a/apps/server/src/lib/jwt.ts +++ b/apps/server/src/lib/jwt.ts @@ -14,10 +14,11 @@ export async function createToken( role: string, username: string | null, mustResetPassword: boolean, + tokenVersion: number = 0, ): Promise { const now = Math.floor(Date.now() / 1000); return sign( - { sub: userId, email, role, username, mustResetPassword, iat: now, exp: now + JWT_EXPIRY_SECONDS }, + { sub: userId, email, role, username, mustResetPassword, tokenVersion, iat: now, exp: now + JWT_EXPIRY_SECONDS }, getJwtSecret(), ); } @@ -28,6 +29,7 @@ export async function verifyToken(token: string): Promise<{ role: string; username: string | null; mustResetPassword: boolean; + tokenVersion: number; }> { const payload = await verify(token, getJwtSecret(), 'HS256'); return { @@ -36,5 +38,6 @@ export async function verifyToken(token: string): Promise<{ role: (payload.role as string) ?? 'user', username: (payload.username as string) ?? null, mustResetPassword: (payload.mustResetPassword as boolean) ?? false, + tokenVersion: (payload.tokenVersion as number) ?? 0, }; } diff --git a/apps/server/src/middleware/auth.ts b/apps/server/src/middleware/auth.ts index 19b83ad..b6047e3 100644 --- a/apps/server/src/middleware/auth.ts +++ b/apps/server/src/middleware/auth.ts @@ -27,6 +27,10 @@ export const authMiddleware = createMiddleware(async (c, next) => { return c.json({ error: 'Invalid token' }, 401); } + if (payload.tokenVersion !== user.tokenVersion) { + return c.json({ error: 'Token has been revoked' }, 401); + } + await db .update(users) .set({ lastSeenAt: new Date() }) @@ -40,6 +44,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { email: user.email, role: user.role, mustResetPassword: user.mustResetPassword, + tokenVersion: user.tokenVersion, }); await next(); } catch { diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index 4065ec7..1898d62 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { eq, or } from 'drizzle-orm'; +import { eq, or, sql } from 'drizzle-orm'; import bcrypt from 'bcryptjs'; import { db } from '../db'; import { users } from '../db/schema'; @@ -15,7 +15,7 @@ auth.post('/anonymous', async (c) => { .values({}) .returning(); - const token = await createToken(user.id, null, 'user', null, false); + const token = await createToken(user.id, null, 'user', null, false, 0); return c.json({ userId: user.id, token }); }); @@ -80,7 +80,7 @@ auth.post('/register', authMiddleware, async (c) => { .where(eq(users.id, userId)) .returning(); - const token = await createToken(updated.id, updated.email, updated.role, updated.username, false); + const token = await createToken(updated.id, updated.email, updated.role, updated.username, false, updated.tokenVersion); return c.json({ userId: updated.id, token }); }); @@ -106,7 +106,7 @@ auth.post('/login', async (c) => { return c.json({ error: 'Invalid credentials' }, 401); } - const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword); + const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword, user.tokenVersion); return c.json({ userId: user.id, token }); }); @@ -141,12 +141,13 @@ auth.post('/change-password', authMiddleware, async (c) => { } const passwordHash = await bcrypt.hash(newPassword, 10); - await db + const [updated] = await db .update(users) - .set({ passwordHash, mustResetPassword: false }) - .where(eq(users.id, user.id)); + .set({ passwordHash, mustResetPassword: false, tokenVersion: sql`${users.tokenVersion} + 1` }) + .where(eq(users.id, user.id)) + .returning({ tokenVersion: users.tokenVersion }); - const token = await createToken(user.id, user.email, user.role, user.username, false); + const token = await createToken(user.id, user.email, user.role, user.username, false, updated.tokenVersion); return c.json({ success: true, token }); }); @@ -176,7 +177,7 @@ auth.post('/change-username', authMiddleware, async (c) => { .set({ username }) .where(eq(users.id, user.id)); - const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword); + const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword, user.tokenVersion); return c.json({ success: true, token }); }); @@ -230,8 +231,29 @@ auth.post('/change-email', authMiddleware, async (c) => { .set({ email }) .where(eq(users.id, user.id)); - const token = await createToken(user.id, email, user.role, user.username, user.mustResetPassword); + const token = await createToken(user.id, email, user.role, user.username, user.mustResetPassword, user.tokenVersion); return c.json({ success: true, token }); }); +auth.post('/logout', authMiddleware, async (c) => { + const user = c.get('user'); + + await db + .update(users) + .set({ tokenVersion: sql`${users.tokenVersion} + 1` }) + .where(eq(users.id, user.id)); + + return c.json({ success: true }); +}); + +auth.get('/me', authMiddleware, async (c) => { + const user = c.get('user'); + return c.json({ + id: user.id, + username: user.username, + email: user.email, + role: user.role, + }); +}); + export { auth }; diff --git a/apps/server/src/routes/saves.ts b/apps/server/src/routes/saves.ts index c0df490..2117193 100644 --- a/apps/server/src/routes/saves.ts +++ b/apps/server/src/routes/saves.ts @@ -28,6 +28,19 @@ savesRouter.get('/', async (c) => { return c.json({ saves: userSaves }); }); +savesRouter.get('/latest', async (c) => { + const userId = c.get('userId') as string; + + const [save] = await db + .select() + .from(saves) + .where(eq(saves.userId, userId)) + .orderBy(desc(saves.updatedAt)) + .limit(1); + + return c.json({ save: save ?? null }); +}); + savesRouter.get('/:id', async (c) => { const userId = c.get('userId') as string; const saveId = c.req.param('id'); diff --git a/apps/server/src/types.ts b/apps/server/src/types.ts index 41075b0..e5a1e35 100644 --- a/apps/server/src/types.ts +++ b/apps/server/src/types.ts @@ -8,6 +8,7 @@ export type AppEnv = { email: string | null; role: string; mustResetPassword: boolean; + tokenVersion: number; }; }; }; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8866d9a..fd8f266 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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(null); @@ -71,6 +72,7 @@ export function App() { }, [companyName, lastTickTimestamp, catchUpDone]); useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset); + useCloudSave(); if (authLoading) { return ; @@ -92,7 +94,7 @@ export function App() { } if (!companyName) { - return ; + return ; } if (catchUpTicks !== null && !catchUpDone) { diff --git a/apps/web/src/components/game/NewGameScreen.tsx b/apps/web/src/components/game/NewGameScreen.tsx index b69d582..7369796 100644 --- a/apps/web/src/components/game/NewGameScreen.tsx +++ b/apps/web/src/components/game/NewGameScreen.tsx @@ -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 = { + 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; +} + +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 (
@@ -32,7 +67,37 @@ export function NewGameScreen() {

+ {cloudSave && onContinue && ( +
+
+ +

Continue Your Game

+
+
+
{cloudSave.companyName}
+
+ + {ERA_LABELS[cloudSave.era] ?? cloudSave.era} + + Tick {cloudSave.tickCount.toLocaleString()} + Saved {formatTimeAgo(cloudSave.updatedAt)} +
+
+ +
+ )} +
+ {cloudSave && onContinue && ( +
Or start fresh
+ )}
diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 503bf33..230c1a7 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -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() { )}
+ {!collapsed && (() => { + const payload = getTokenPayload(); + const displayName = payload?.username || payload?.email || 'Guest'; + return
{displayName}
; + })()} {collapsed ? 'v0.1' : 'Token Empire v0.1'}
diff --git a/apps/web/src/hooks/useAuthGate.ts b/apps/web/src/hooks/useAuthGate.ts index 9065263..0defc58 100644 --- a/apps/web/src/hooks/useAuthGate.ts +++ b/apps/web/src/hooks/useAuthGate.ts @@ -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; 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(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; + 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, diff --git a/apps/web/src/hooks/useCloudSave.ts b/apps/web/src/hooks/useCloudSave.ts index 22ac093..dde7782 100644 --- a/apps/web/src/hooks/useCloudSave.ts +++ b/apps/web/src/hooks/useCloudSave.ts @@ -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]); } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 39c17a1..7616663 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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(path: string, options: RequestInit & { timeoutMs?: number } = {}): Promise { const { timeoutMs = 10_000, ...fetchOptions } = options; @@ -86,6 +89,11 @@ async function request(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' }), diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 8429920..1dbe067 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -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() { ) : (
Playing as guest.
)} + +
+ +
@@ -290,6 +302,24 @@ export function SettingsPage() { onCancel={() => setImportData(null)} /> )} + + {showLogoutConfirm && ( + { + try { await api.auth.logout(); } catch {} + clearAuthToken(); + localStorage.removeItem('token-empire-save'); + window.location.reload(); + }} + onCancel={() => setShowLogoutConfirm(false)} + /> + )}
); }