Revitalize backend: working cloud saves, logout, and account UX
Cloud saves were fully built but never wired up — useCloudSave() hook was never called, no load-from-cloud flow existed, and there was no way to continue a saved game. Logout was completely missing (no endpoint, no UI). Accounts felt like a gate behind the invite wall rather than real accounts. Backend: add tokenVersion to users for server-side token invalidation, POST /auth/logout bumps it to revoke all JWTs, GET /auth/me returns profile, GET /saves/latest returns most recent save with full gameData. All createToken calls now include tokenVersion. Auth middleware rejects tokens with stale tokenVersion. Frontend: wire up useCloudSave() in App (auto-saves every 60 ticks with error handling), fetch cloud save on startup for registered users, show "Continue Your Game" card on NewGameScreen, add Log Out button with confirmation in Settings, show username in sidebar, 401 interceptor clears auth and reloads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "token_version" integer DEFAULT 0 NOT NULL;
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type AppEnv = {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
mustResetPassword: boolean;
|
mustResetPassword: boolean;
|
||||||
|
tokenVersion: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user