From 2a6629af790dcbbd410ee07b61c4dda29b4b66cc Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 28 Apr 2026 19:32:03 -0400 Subject: [PATCH] Revitalize backend: working cloud saves, logout, and account UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../drizzle/0001_certain_aaron_stack.sql | 1 + apps/server/drizzle/meta/0001_snapshot.json | 477 ++++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + apps/server/src/db/schema.ts | 1 + apps/server/src/lib/jwt.ts | 5 +- apps/server/src/middleware/auth.ts | 5 + apps/server/src/routes/auth.ts | 42 +- apps/server/src/routes/saves.ts | 13 + apps/server/src/types.ts | 1 + apps/web/src/App.tsx | 6 +- .../web/src/components/game/NewGameScreen.tsx | 71 ++- apps/web/src/components/layout/Sidebar.tsx | 7 +- apps/web/src/hooks/useAuthGate.ts | 49 +- apps/web/src/hooks/useCloudSave.ts | 23 +- apps/web/src/lib/api.ts | 13 + apps/web/src/pages/SettingsPage.tsx | 34 +- 16 files changed, 732 insertions(+), 23 deletions(-) create mode 100644 apps/server/drizzle/0001_certain_aaron_stack.sql create mode 100644 apps/server/drizzle/meta/0001_snapshot.json 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)} + /> + )}
); } -- 2.39.5