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

Merged
josh merged 1 commits from feature/auth-invites into main 2026-04-28 19:34:37 -04:00
16 changed files with 732 additions and 23 deletions
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "token_version" integer DEFAULT 0 NOT NULL;
+477
View File
@@ -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": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1777333216602, "when": 1777333216602,
"tag": "0000_tearful_hedge_knight", "tag": "0000_tearful_hedge_knight",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1777417629552,
"tag": "0001_certain_aaron_stack",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -8,6 +8,7 @@ export const users = pgTable('users', {
passwordHash: text('password_hash'), passwordHash: text('password_hash'),
role: text('role').notNull().default('user'), role: text('role').notNull().default('user'),
mustResetPassword: boolean('must_reset_password').notNull().default(false), mustResetPassword: boolean('must_reset_password').notNull().default(false),
tokenVersion: integer('token_version').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(), lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
}); });
+4 -1
View File
@@ -14,10 +14,11 @@ export async function createToken(
role: string, role: string,
username: string | null, username: string | null,
mustResetPassword: boolean, mustResetPassword: boolean,
tokenVersion: number = 0,
): Promise<string> { ): Promise<string> {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
return sign( 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(), getJwtSecret(),
); );
} }
@@ -28,6 +29,7 @@ export async function verifyToken(token: string): Promise<{
role: string; role: string;
username: string | null; username: string | null;
mustResetPassword: boolean; mustResetPassword: boolean;
tokenVersion: number;
}> { }> {
const payload = await verify(token, getJwtSecret(), 'HS256'); const payload = await verify(token, getJwtSecret(), 'HS256');
return { return {
@@ -36,5 +38,6 @@ export async function verifyToken(token: string): Promise<{
role: (payload.role as string) ?? 'user', role: (payload.role as string) ?? 'user',
username: (payload.username as string) ?? null, username: (payload.username as string) ?? null,
mustResetPassword: (payload.mustResetPassword as boolean) ?? false, mustResetPassword: (payload.mustResetPassword as boolean) ?? false,
tokenVersion: (payload.tokenVersion as number) ?? 0,
}; };
} }
+5
View File
@@ -27,6 +27,10 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
return c.json({ error: 'Invalid token' }, 401); return c.json({ error: 'Invalid token' }, 401);
} }
if (payload.tokenVersion !== user.tokenVersion) {
return c.json({ error: 'Token has been revoked' }, 401);
}
await db await db
.update(users) .update(users)
.set({ lastSeenAt: new Date() }) .set({ lastSeenAt: new Date() })
@@ -40,6 +44,7 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
email: user.email, email: user.email,
role: user.role, role: user.role,
mustResetPassword: user.mustResetPassword, mustResetPassword: user.mustResetPassword,
tokenVersion: user.tokenVersion,
}); });
await next(); await next();
} catch { } catch {
+32 -10
View File
@@ -1,5 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { eq, or } from 'drizzle-orm'; import { eq, or, sql } from 'drizzle-orm';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { db } from '../db'; import { db } from '../db';
import { users } from '../db/schema'; import { users } from '../db/schema';
@@ -15,7 +15,7 @@ auth.post('/anonymous', async (c) => {
.values({}) .values({})
.returning(); .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 }); return c.json({ userId: user.id, token });
}); });
@@ -80,7 +80,7 @@ auth.post('/register', authMiddleware, async (c) => {
.where(eq(users.id, userId)) .where(eq(users.id, userId))
.returning(); .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 }); return c.json({ userId: updated.id, token });
}); });
@@ -106,7 +106,7 @@ auth.post('/login', async (c) => {
return c.json({ error: 'Invalid credentials' }, 401); 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 }); 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); const passwordHash = await bcrypt.hash(newPassword, 10);
await db const [updated] = await db
.update(users) .update(users)
.set({ passwordHash, mustResetPassword: false }) .set({ passwordHash, mustResetPassword: false, tokenVersion: sql`${users.tokenVersion} + 1` })
.where(eq(users.id, user.id)); .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 }); return c.json({ success: true, token });
}); });
@@ -176,7 +177,7 @@ auth.post('/change-username', authMiddleware, async (c) => {
.set({ username }) .set({ username })
.where(eq(users.id, user.id)); .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 }); return c.json({ success: true, token });
}); });
@@ -230,8 +231,29 @@ auth.post('/change-email', authMiddleware, async (c) => {
.set({ email }) .set({ email })
.where(eq(users.id, user.id)); .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 }); 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 }; export { auth };
+13
View File
@@ -28,6 +28,19 @@ savesRouter.get('/', async (c) => {
return c.json({ saves: userSaves }); 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) => { savesRouter.get('/:id', async (c) => {
const userId = c.get('userId') as string; const userId = c.get('userId') as string;
const saveId = c.req.param('id'); const saveId = c.req.param('id');
+1
View File
@@ -8,6 +8,7 @@ export type AppEnv = {
email: string | null; email: string | null;
role: string; role: string;
mustResetPassword: boolean; mustResetPassword: boolean;
tokenVersion: number;
}; };
}; };
}; };
+4 -2
View File
@@ -6,6 +6,7 @@ import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
import { InviteGateScreen } from '@/components/game/InviteGateScreen'; import { InviteGateScreen } from '@/components/game/InviteGateScreen';
import { useGameLoop } from '@/hooks/useGameLoop'; import { useGameLoop } from '@/hooks/useGameLoop';
import { useAuthGate } from '@/hooks/useAuthGate'; import { useAuthGate } from '@/hooks/useAuthGate';
import { useCloudSave } from '@/hooks/useCloudSave';
import { TICK_INTERVAL_MS } from '@token-empire/shared'; import { TICK_INTERVAL_MS } from '@token-empire/shared';
import { Sparkles, RefreshCw, WifiOff } from 'lucide-react'; import { Sparkles, RefreshCw, WifiOff } from 'lucide-react';
@@ -53,7 +54,7 @@ function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () =>
} }
export function App() { 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 companyName = useGameStore((s) => s.meta.companyName);
const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp); const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp);
const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null); const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null);
@@ -71,6 +72,7 @@ export function App() {
}, [companyName, lastTickTimestamp, catchUpDone]); }, [companyName, lastTickTimestamp, catchUpDone]);
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset); useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset);
useCloudSave();
if (authLoading) { if (authLoading) {
return <LoadingScreen />; return <LoadingScreen />;
@@ -92,7 +94,7 @@ export function App() {
} }
if (!companyName) { if (!companyName) {
return <NewGameScreen />; return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />;
} }
if (catchUpTicks !== null && !catchUpDone) { if (catchUpTicks !== null && !catchUpDone) {
+68 -3
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Sparkles } from 'lucide-react'; import { Sparkles, Cloud, Play } from 'lucide-react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import type { CloudSaveInfo } from '@/hooks/useAuthGate';
const SUGGESTED_NAMES = [ const SUGGESTED_NAMES = [
'Nexus AI', 'Cortex Labs', 'Synapse Technologies', 'Nexus AI', 'Cortex Labs', 'Synapse Technologies',
@@ -8,8 +9,32 @@ const SUGGESTED_NAMES = [
'Neural Forge', 'DeepMind+', 'Cerebral Systems', '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 [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const startNewGame = useGameStore((s) => s.startNewGame); const startNewGame = useGameStore((s) => s.startNewGame);
const handleStart = () => { const handleStart = () => {
@@ -17,6 +42,16 @@ export function NewGameScreen() {
startNewGame(companyName); startNewGame(companyName);
}; };
const handleContinue = async () => {
if (!onContinue) return;
setLoading(true);
try {
await onContinue();
} finally {
setLoading(false);
}
};
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900"> <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"> <div className="max-w-md w-full mx-4">
@@ -32,7 +67,37 @@ export function NewGameScreen() {
</p> </p>
</div> </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"> <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> <div>
<label className="block text-sm font-medium text-surface-300 mb-2"> <label className="block text-sm font-medium text-surface-300 mb-2">
Name your AI company Name your AI company
@@ -44,7 +109,7 @@ export function NewGameScreen() {
onKeyDown={(e) => e.key === 'Enter' && handleStart()} onKeyDown={(e) => e.key === 'Enter' && handleStart()}
placeholder={SUGGESTED_NAMES[0]} 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" 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} maxLength={30}
/> />
</div> </div>
+6 -1
View File
@@ -5,7 +5,7 @@ import {
PanelLeftClose, PanelLeftOpen, Mail, UserPlus, Copy, Check, PanelLeftClose, PanelLeftOpen, Mail, UserPlus, Copy, Check,
} from 'lucide-react'; } from 'lucide-react';
import { useGameStore, type ActivePage } from '@/store'; 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 }[] = [ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string; adminOnly?: boolean }[] = [
{ page: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { 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`}> <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'} {collapsed ? 'v0.1' : 'Token Empire v0.1'}
</div> </div>
</aside> </aside>
+48 -1
View File
@@ -1,7 +1,16 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { api, getTokenPayload, isRegistered as checkRegistered, needsPasswordReset as checkNeedsReset, validateStoredToken } from '@/lib/api'; import { api, getTokenPayload, isRegistered as checkRegistered, needsPasswordReset as checkNeedsReset, validateStoredToken } from '@/lib/api';
import { useGameStore } from '@/store';
import { ensureAuth } from './useCloudSave'; import { ensureAuth } from './useCloudSave';
export interface CloudSaveInfo {
id: string;
companyName: string;
era: string;
tickCount: number;
updatedAt: string;
}
interface AuthGateState { interface AuthGateState {
loading: boolean; loading: boolean;
backendError: string | null; backendError: string | null;
@@ -10,6 +19,8 @@ interface AuthGateState {
registered: boolean; registered: boolean;
isAdmin: boolean; isAdmin: boolean;
config: { requireInvite: boolean; userInvitations: number } | null; config: { requireInvite: boolean; userInvitations: number } | null;
cloudSave: CloudSaveInfo | null;
loadCloudSave: () => Promise<void>;
setRegistered: (value: boolean) => void; setRegistered: (value: boolean) => void;
setNeedsPasswordReset: (value: boolean) => void; setNeedsPasswordReset: (value: boolean) => void;
retry: () => void; retry: () => void;
@@ -22,6 +33,7 @@ export function useAuthGate(): AuthGateState {
const [registered, setRegistered] = useState(false); const [registered, setRegistered] = useState(false);
const [passwordReset, setPasswordReset] = useState(false); const [passwordReset, setPasswordReset] = useState(false);
const [admin, setAdmin] = useState(false); const [admin, setAdmin] = useState(false);
const [cloudSave, setCloudSave] = useState<CloudSaveInfo | null>(null);
const [initCount, setInitCount] = useState(0); const [initCount, setInitCount] = useState(0);
const init = useCallback(async () => { const init = useCallback(async () => {
@@ -52,9 +64,30 @@ export function useAuthGate(): AuthGateState {
} }
const payload = getTokenPayload(); const payload = getTokenPayload();
setRegistered(checkRegistered()); const isReg = checkRegistered();
setRegistered(isReg);
setPasswordReset(checkNeedsReset()); setPasswordReset(checkNeedsReset());
setAdmin(payload?.role === 'admin'); 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); setLoading(false);
}, []); }, []);
@@ -66,6 +99,18 @@ export function useAuthGate(): AuthGateState {
init(); init();
}, [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) => { const handleSetRegistered = useCallback((value: boolean) => {
setRegistered(value); setRegistered(value);
const payload = getTokenPayload(); const payload = getTokenPayload();
@@ -89,6 +134,8 @@ export function useAuthGate(): AuthGateState {
registered, registered,
isAdmin: admin, isAdmin: admin,
config, config,
cloudSave,
loadCloudSave,
setRegistered: handleSetRegistered, setRegistered: handleSetRegistered,
setNeedsPasswordReset: handleSetPasswordReset, setNeedsPasswordReset: handleSetPasswordReset,
retry, retry,
+20 -3
View File
@@ -3,22 +3,27 @@ import { useGameStore } from '@/store';
import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api'; import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api';
import { AUTO_SAVE_INTERVAL_TICKS } from '@token-empire/shared'; import { AUTO_SAVE_INTERVAL_TICKS } from '@token-empire/shared';
const MAX_CONSECUTIVE_FAILURES = 3;
export function useCloudSave() { export function useCloudSave() {
const tickCount = useGameStore((s) => s.meta.tickCount); const tickCount = useGameStore((s) => s.meta.tickCount);
const companyName = useGameStore((s) => s.meta.companyName); const companyName = useGameStore((s) => s.meta.companyName);
const lastSaveTick = useRef(0); const lastSaveTick = useRef(0);
const failureCount = useRef(0);
useEffect(() => { useEffect(() => {
if (!companyName) return; 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(); const token = getAuthToken();
if (!token) return; if (!token) return;
if (failureCount.current >= MAX_CONSECUTIVE_FAILURES) return;
lastSaveTick.current = tickCount; lastSaveTick.current = tickCount;
const state = useGameStore.getState(); const state = useGameStore.getState();
const { activePage, notifications, ...gameState } = state; const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
api.saves.put({ api.saves.put({
companyName: state.meta.companyName, companyName: state.meta.companyName,
@@ -26,7 +31,19 @@ export function useCloudSave() {
gameData: gameState, gameData: gameState,
tickCount: state.meta.tickCount, tickCount: state.meta.tickCount,
era: state.meta.currentEra, 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]); }, [tickCount, companyName]);
} }
+13
View File
@@ -14,6 +14,7 @@ export function getAuthToken() {
export function clearAuthToken() { export function clearAuthToken() {
authToken = null; authToken = null;
localStorage.removeItem('token-empire-auth-token'); localStorage.removeItem('token-empire-auth-token');
localStorage.removeItem('token-empire-refresh-token');
} }
export interface TokenPayload { export interface TokenPayload {
@@ -63,6 +64,8 @@ export function needsPasswordReset(): boolean {
return payload?.mustResetPassword === true; 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> { async function request<T>(path: string, options: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
const { timeoutMs = 10_000, ...fetchOptions } = options; 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.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); const body = await res.json().catch(() => null);
throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`); throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`);
} }
@@ -140,6 +148,10 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, currentPassword }), 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: { config: {
get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'), get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'),
@@ -164,6 +176,7 @@ export const api = {
saves: { saves: {
list: () => request<{ saves: Array<{ id: string; companyName: string; era: string; tickCount: number; updatedAt: string }> }>('/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}`), 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 }) => put: (data: { companyName: string; saveVersion: number; gameData: unknown; tickCount: number; era: string }) =>
request<{ id: string }>('/api/saves', { method: 'PUT', body: JSON.stringify(data) }), request<{ id: string }>('/api/saves', { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: string) => request<{ deleted: boolean }>(`/api/saves/${id}`, { method: 'DELETE' }), delete: (id: string) => request<{ deleted: boolean }>(`/api/saves/${id}`, { method: 'DELETE' }),
+32 -2
View File
@@ -1,8 +1,8 @@
import { useRef, useState } from 'react'; 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 { useGameStore } from '@/store';
import { ConfirmModal } from '@/components/common/ConfirmModal'; 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() { export function SettingsPage() {
const settings = useGameStore((s) => s.meta.settings); const settings = useGameStore((s) => s.meta.settings);
@@ -18,6 +18,8 @@ export function SettingsPage() {
const [usernameError, setUsernameError] = useState(''); const [usernameError, setUsernameError] = useState('');
const [usernameSaving, setUsernameSaving] = useState(false); const [usernameSaving, setUsernameSaving] = useState(false);
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
const [editingEmail, setEditingEmail] = useState(false); const [editingEmail, setEditingEmail] = useState(false);
const [emailValue, setEmailValue] = useState(''); const [emailValue, setEmailValue] = useState('');
const [emailPassword, setEmailPassword] = 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="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>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4"> <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)} 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> </div>
); );
} }