Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce2cab3404 | |||
| 035d4f0385 | |||
| 5e4007160c | |||
| eca244f9d4 | |||
| 95b43dceec | |||
| 01d9703aec | |||
| 8e5dca471e | |||
| cc27c00991 | |||
| c0965cb7d7 | |||
| 5f7b728463 | |||
| b0c552562a |
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "users" ADD COLUMN "token_version" integer DEFAULT 0 NOT NULL;
|
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
{
|
|
||||||
"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,13 +8,6 @@
|
|||||||
"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,7 +8,6 @@ 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,11 +14,10 @@ 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, tokenVersion, iat: now, exp: now + JWT_EXPIRY_SECONDS },
|
{ sub: userId, email, role, username, mustResetPassword, iat: now, exp: now + JWT_EXPIRY_SECONDS },
|
||||||
getJwtSecret(),
|
getJwtSecret(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -29,7 +28,6 @@ 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 {
|
||||||
@@ -38,6 +36,5 @@ 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,10 +27,6 @@ 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() })
|
||||||
@@ -44,7 +40,6 @@ 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, sql } from 'drizzle-orm';
|
import { eq, or } 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, 0);
|
const token = await createToken(user.id, null, 'user', null, false);
|
||||||
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, updated.tokenVersion);
|
const token = await createToken(updated.id, updated.email, updated.role, updated.username, false);
|
||||||
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, user.tokenVersion);
|
const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword);
|
||||||
return c.json({ userId: user.id, token });
|
return c.json({ userId: user.id, token });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,13 +141,12 @@ auth.post('/change-password', authMiddleware, async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||||
const [updated] = await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ passwordHash, mustResetPassword: false, tokenVersion: sql`${users.tokenVersion} + 1` })
|
.set({ passwordHash, mustResetPassword: false })
|
||||||
.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, updated.tokenVersion);
|
const token = await createToken(user.id, user.email, user.role, user.username, false);
|
||||||
return c.json({ success: true, token });
|
return c.json({ success: true, token });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +176,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, user.tokenVersion);
|
const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword);
|
||||||
return c.json({ success: true, token });
|
return c.json({ success: true, token });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,29 +230,8 @@ 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, user.tokenVersion);
|
const token = await createToken(user.id, email, user.role, user.username, user.mustResetPassword);
|
||||||
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,19 +28,6 @@ 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,7 +8,6 @@ export type AppEnv = {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
mustResetPassword: boolean;
|
mustResetPassword: boolean;
|
||||||
tokenVersion: number;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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';
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, loadCloudSave, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
|
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, 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);
|
||||||
@@ -72,7 +71,6 @@ 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 />;
|
||||||
@@ -94,7 +92,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!companyName) {
|
if (!companyName) {
|
||||||
return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />;
|
return <NewGameScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (catchUpTicks !== null && !catchUpDone) {
|
if (catchUpTicks !== null && !catchUpDone) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Sparkles, Cloud, Play } from 'lucide-react';
|
import { Sparkles } 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',
|
||||||
@@ -9,32 +8,8 @@ const SUGGESTED_NAMES = [
|
|||||||
'Neural Forge', 'DeepMind+', 'Cerebral Systems',
|
'Neural Forge', 'DeepMind+', 'Cerebral Systems',
|
||||||
];
|
];
|
||||||
|
|
||||||
const ERA_LABELS: Record<string, string> = {
|
export function NewGameScreen() {
|
||||||
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 = () => {
|
||||||
@@ -42,16 +17,6 @@ export function NewGameScreen({ cloudSave, onContinue }: Props) {
|
|||||||
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">
|
||||||
@@ -67,37 +32,7 @@ export function NewGameScreen({ cloudSave, onContinue }: Props) {
|
|||||||
</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
|
||||||
@@ -109,7 +44,7 @@ export function NewGameScreen({ cloudSave, onContinue }: Props) {
|
|||||||
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={!cloudSave}
|
autoFocus
|
||||||
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, getTokenPayload, api } from '@/lib/api';
|
import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, 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,11 +166,6 @@ 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,16 +1,7 @@
|
|||||||
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;
|
||||||
@@ -19,8 +10,6 @@ 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;
|
||||||
@@ -33,7 +22,6 @@ 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 () => {
|
||||||
@@ -64,30 +52,9 @@ export function useAuthGate(): AuthGateState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = getTokenPayload();
|
const payload = getTokenPayload();
|
||||||
const isReg = checkRegistered();
|
setRegistered(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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -99,18 +66,6 @@ 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();
|
||||||
@@ -134,8 +89,6 @@ 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,27 +3,22 @@ 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) return;
|
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS * 5) 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, infraNav, modelsTab, ...gameState } = state;
|
const { activePage, notifications, ...gameState } = state;
|
||||||
|
|
||||||
api.saves.put({
|
api.saves.put({
|
||||||
companyName: state.meta.companyName,
|
companyName: state.meta.companyName,
|
||||||
@@ -31,19 +26,7 @@ export function useCloudSave() {
|
|||||||
gameData: gameState,
|
gameData: gameState,
|
||||||
tickCount: state.meta.tickCount,
|
tickCount: state.meta.tickCount,
|
||||||
era: state.meta.currentEra,
|
era: state.meta.currentEra,
|
||||||
}).then(() => {
|
}).catch(() => {});
|
||||||
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,7 +14,6 @@ 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 {
|
||||||
@@ -64,8 +63,6 @@ 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;
|
||||||
|
|
||||||
@@ -89,11 +86,6 @@ 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}`);
|
||||||
}
|
}
|
||||||
@@ -148,10 +140,6 @@ 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'),
|
||||||
@@ -176,7 +164,6 @@ 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, LogOut } from 'lucide-react';
|
import { Pencil, Check, X } 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, clearAuthToken } from '@/lib/api';
|
import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin } from '@/lib/api';
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const settings = useGameStore((s) => s.meta.settings);
|
const settings = useGameStore((s) => s.meta.settings);
|
||||||
@@ -18,8 +18,6 @@ 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('');
|
||||||
@@ -209,16 +207,6 @@ 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">
|
||||||
@@ -302,24 +290,6 @@ 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