Merge pull request 'Add auth system with invite-only registration and admin roles' (#1) from feature/auth-invites into main
CI / build-and-push (push) Successful in 1m21s
CI / build-and-push (push) Successful in 1m21s
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-tycoon/shared": "workspace:*",
|
"@ai-tycoon/shared": "workspace:*",
|
||||||
"@hono/node-server": "^1.13.8",
|
"@hono/node-server": "^1.13.8",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"drizzle-orm": "^0.44.2",
|
"drizzle-orm": "^0.44.2",
|
||||||
"hono": "^4.7.10",
|
"hono": "^4.7.10",
|
||||||
"postgres": "^3.4.7",
|
"postgres": "^3.4.7",
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { pgTable, uuid, text, timestamp, jsonb, integer, boolean, index } from '
|
|||||||
export const users = pgTable('users', {
|
export const users = pgTable('users', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
anonToken: uuid('anon_token').defaultRandom().notNull().unique(),
|
anonToken: uuid('anon_token').defaultRandom().notNull().unique(),
|
||||||
|
username: text('username').unique(),
|
||||||
email: text('email').unique(),
|
email: text('email').unique(),
|
||||||
passwordHash: text('password_hash'),
|
passwordHash: text('password_hash'),
|
||||||
|
role: text('role').notNull().default('user'),
|
||||||
|
mustResetPassword: boolean('must_reset_password').notNull().default(false),
|
||||||
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(),
|
||||||
});
|
});
|
||||||
@@ -44,3 +47,12 @@ export const achievements = pgTable('achievements', {
|
|||||||
}, (table) => [
|
}, (table) => [
|
||||||
index('achievements_user_id_idx').on(table.userId),
|
index('achievements_user_id_idx').on(table.userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const invitations = pgTable('invitations', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
code: text('code').notNull().unique(),
|
||||||
|
createdBy: uuid('created_by').notNull().references(() => users.id),
|
||||||
|
usedBy: uuid('used_by').references(() => users.id),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
expiresAt: timestamp('expires_at'),
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { db } from './index';
|
||||||
|
import { users } from './schema';
|
||||||
|
|
||||||
|
export async function seedAdmin() {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, 'admin'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
console.log('Admin user already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash('admin', 10);
|
||||||
|
await db.insert(users).values({
|
||||||
|
username: 'admin',
|
||||||
|
passwordHash,
|
||||||
|
role: 'admin',
|
||||||
|
mustResetPassword: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Admin user seeded (admin/admin — password reset required)');
|
||||||
|
}
|
||||||
@@ -5,6 +5,13 @@ import { serve } from '@hono/node-server';
|
|||||||
import { auth } from './routes/auth';
|
import { auth } from './routes/auth';
|
||||||
import { savesRouter } from './routes/saves';
|
import { savesRouter } from './routes/saves';
|
||||||
import { leaderboardRouter } from './routes/leaderboard';
|
import { leaderboardRouter } from './routes/leaderboard';
|
||||||
|
import { invitesRouter } from './routes/invites';
|
||||||
|
import { seedAdmin } from './db/seed';
|
||||||
|
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
console.error('FATAL: JWT_SECRET environment variable is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -19,12 +26,20 @@ app.use('*', cors({
|
|||||||
|
|
||||||
app.get('/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
app.get('/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
||||||
|
|
||||||
|
app.get('/api/config', (c) => c.json({
|
||||||
|
requireInvite: process.env.REQUIRE_INVITE !== 'false',
|
||||||
|
userInvitations: parseInt(process.env.USER_INVITATIONS || '0', 10),
|
||||||
|
}));
|
||||||
|
|
||||||
app.route('/api/auth', auth);
|
app.route('/api/auth', auth);
|
||||||
app.route('/api/saves', savesRouter);
|
app.route('/api/saves', savesRouter);
|
||||||
app.route('/api/leaderboard', leaderboardRouter);
|
app.route('/api/leaderboard', leaderboardRouter);
|
||||||
|
app.route('/api/invites', invitesRouter);
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 3001;
|
const port = Number(process.env.PORT) || 3001;
|
||||||
|
|
||||||
console.log(`AI Tycoon API server starting on port ${port}...`);
|
console.log(`AI Tycoon API server starting on port ${port}...`);
|
||||||
|
|
||||||
|
await seedAdmin();
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port });
|
serve({ fetch: app.fetch, port });
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { sign, verify } from 'hono/jwt';
|
||||||
|
|
||||||
|
const JWT_EXPIRY_SECONDS = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
export function getJwtSecret(): string {
|
||||||
|
const secret = process.env.JWT_SECRET;
|
||||||
|
if (!secret) throw new Error('JWT_SECRET env var is required');
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createToken(
|
||||||
|
userId: string,
|
||||||
|
email: string | null,
|
||||||
|
role: string,
|
||||||
|
username: string | null,
|
||||||
|
mustResetPassword: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return sign(
|
||||||
|
{ sub: userId, email, role, username, mustResetPassword, iat: now, exp: now + JWT_EXPIRY_SECONDS },
|
||||||
|
getJwtSecret(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyToken(token: string): Promise<{
|
||||||
|
sub: string;
|
||||||
|
email: string | null;
|
||||||
|
role: string;
|
||||||
|
username: string | null;
|
||||||
|
mustResetPassword: boolean;
|
||||||
|
}> {
|
||||||
|
const payload = await verify(token, getJwtSecret(), 'HS256');
|
||||||
|
return {
|
||||||
|
sub: payload.sub as string,
|
||||||
|
email: (payload.email as string) ?? null,
|
||||||
|
role: (payload.role as string) ?? 'user',
|
||||||
|
username: (payload.username as string) ?? null,
|
||||||
|
mustResetPassword: (payload.mustResetPassword as boolean) ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createMiddleware } from 'hono/factory';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { users } from '../db/schema';
|
import { users } from '../db/schema';
|
||||||
|
import { verifyToken } from '../lib/jwt';
|
||||||
import type { AppEnv } from '../types';
|
import type { AppEnv } from '../types';
|
||||||
|
|
||||||
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
||||||
@@ -14,10 +15,12 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
|||||||
const token = authHeader.slice(7);
|
const token = authHeader.slice(7);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const payload = await verifyToken(token);
|
||||||
|
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.anonToken, token))
|
.where(eq(users.id, payload.sub))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -30,9 +33,24 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
|||||||
.where(eq(users.id, user.id));
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
c.set('userId', user.id);
|
c.set('userId', user.id);
|
||||||
c.set('user', user as AppEnv['Variables']['user']);
|
c.set('user', {
|
||||||
|
id: user.id,
|
||||||
|
anonToken: user.anonToken,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
mustResetPassword: user.mustResetPassword,
|
||||||
|
});
|
||||||
await next();
|
await next();
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ error: 'Authentication failed' }, 500);
|
return c.json({ error: 'Invalid or expired token' }, 401);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const requireAdmin = createMiddleware<AppEnv>(async (c, next) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|||||||
+134
-33
@@ -1,7 +1,10 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, or } from 'drizzle-orm';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { users } from '../db/schema';
|
import { users } from '../db/schema';
|
||||||
|
import { createToken } from '../lib/jwt';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import type { AppEnv } from '../types';
|
import type { AppEnv } from '../types';
|
||||||
|
|
||||||
const auth = new Hono<AppEnv>();
|
const auth = new Hono<AppEnv>();
|
||||||
@@ -12,20 +15,51 @@ auth.post('/anonymous', async (c) => {
|
|||||||
.values({})
|
.values({})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return c.json({
|
const token = await createToken(user.id, null, 'user', null, false);
|
||||||
userId: user.id,
|
return c.json({ userId: user.id, token });
|
||||||
token: user.anonToken,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
auth.post('/link-email', async (c) => {
|
auth.post('/register', authMiddleware, async (c) => {
|
||||||
const userId = c.get('userId') as string;
|
const userId = c.get('userId');
|
||||||
if (!userId) return c.json({ error: 'Not authenticated' }, 401);
|
const { email, password, inviteCode } = await c.req.json<{
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
inviteCode: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
const { email, password } = await c.req.json<{ email: string; password: string }>();
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return c.json({ error: 'Valid email required' }, 400);
|
||||||
|
}
|
||||||
|
if (!password || password.length < 8) {
|
||||||
|
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (!email || !password) {
|
if (process.env.REQUIRE_INVITE !== 'false') {
|
||||||
return c.json({ error: 'Email and password required' }, 400);
|
if (!inviteCode) {
|
||||||
|
return c.json({ error: 'Invite code required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { invitations } = await import('../db/schema');
|
||||||
|
const { isNull, and, sql } = await import('drizzle-orm');
|
||||||
|
|
||||||
|
const [consumed] = await db
|
||||||
|
.update(invitations)
|
||||||
|
.set({ usedBy: userId })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invitations.code, inviteCode),
|
||||||
|
isNull(invitations.usedBy),
|
||||||
|
or(
|
||||||
|
isNull(invitations.expiresAt),
|
||||||
|
sql`${invitations.expiresAt} > NOW()`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!consumed) {
|
||||||
|
return c.json({ error: 'Invalid or used invite code' }, 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
@@ -38,45 +72,112 @@ auth.post('/link-email', async (c) => {
|
|||||||
return c.json({ error: 'Email already in use' }, 409);
|
return c.json({ error: 'Email already in use' }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
const data = encoder.encode(password);
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
||||||
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
await db
|
const [updated] = await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ email, passwordHash: hashHex })
|
.set({ email, passwordHash })
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
return c.json({ success: true });
|
const token = await createToken(updated.id, updated.email, updated.role, updated.username, false);
|
||||||
|
return c.json({ userId: updated.id, token });
|
||||||
});
|
});
|
||||||
|
|
||||||
auth.post('/login', async (c) => {
|
auth.post('/login', async (c) => {
|
||||||
const { email, password } = await c.req.json<{ email: string; password: string }>();
|
const { login, password } = await c.req.json<{ login: string; password: string }>();
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
if (!login || !password) {
|
||||||
const data = encoder.encode(password);
|
return c.json({ error: 'Login and password required' }, 400);
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
}
|
||||||
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, email))
|
.where(or(eq(users.email, login), eq(users.username, login)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!user || user.passwordHash !== hashHex) {
|
if (!user || !user.passwordHash) {
|
||||||
return c.json({ error: 'Invalid credentials' }, 401);
|
return c.json({ error: 'Invalid credentials' }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
userId: user.id,
|
if (!valid) {
|
||||||
token: user.anonToken,
|
return c.json({ error: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword);
|
||||||
|
return c.json({ userId: user.id, token });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auth.post('/change-password', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const { currentPassword, newPassword } = await c.req.json<{
|
||||||
|
currentPassword?: string;
|
||||||
|
newPassword: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
if (!newPassword || newPassword.length < 8) {
|
||||||
|
return c.json({ error: 'New password must be at least 8 characters' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.mustResetPassword) {
|
||||||
|
if (!currentPassword) {
|
||||||
|
return c.json({ error: 'Current password required' }, 400);
|
||||||
|
}
|
||||||
|
const [dbUser] = await db
|
||||||
|
.select({ passwordHash: users.passwordHash })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!dbUser?.passwordHash) {
|
||||||
|
return c.json({ error: 'No password set' }, 400);
|
||||||
|
}
|
||||||
|
const valid = await bcrypt.compare(currentPassword, dbUser.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
return c.json({ error: 'Current password is incorrect' }, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ passwordHash, mustResetPassword: false })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
const token = await createToken(user.id, user.email, user.role, user.username, false);
|
||||||
|
return c.json({ success: true, token });
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.post('/change-username', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username } = await c.req.json<{ username: string }>();
|
||||||
|
if (!username || username.length < 2) {
|
||||||
|
return c.json({ error: 'Username must be at least 2 characters' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0 && existing[0].id !== user.id) {
|
||||||
|
return c.json({ error: 'Username already taken' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ username })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword);
|
||||||
|
return c.json({ success: true, token });
|
||||||
});
|
});
|
||||||
|
|
||||||
export { auth };
|
export { auth };
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { eq, and, isNull, or, sql, count, desc } from 'drizzle-orm';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { invitations, users } from '../db/schema';
|
||||||
|
import { authMiddleware, requireAdmin } from '../middleware/auth';
|
||||||
|
import type { AppEnv } from '../types';
|
||||||
|
|
||||||
|
const invitesRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
function generateCode(): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||||||
|
const bytes = crypto.randomBytes(8);
|
||||||
|
let code = '';
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
code += chars[bytes[i] % chars.length];
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
invitesRouter.post('/', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
|
||||||
|
if (!user.email && user.role !== 'admin') {
|
||||||
|
return c.json({ error: 'Must be registered to create invites' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
const limit = parseInt(process.env.USER_INVITATIONS || '0', 10);
|
||||||
|
if (limit <= 0) {
|
||||||
|
return c.json({ error: 'You are not allowed to create invites' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ value: created }] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(invitations)
|
||||||
|
.where(eq(invitations.createdBy, user.id));
|
||||||
|
|
||||||
|
if (created >= limit) {
|
||||||
|
return c.json({ error: 'Invite limit reached' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
const code = generateCode();
|
||||||
|
try {
|
||||||
|
await db.insert(invitations).values({ code, createdBy: user.id });
|
||||||
|
return c.json({ code });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : '';
|
||||||
|
if (message.includes('unique') || message.includes('duplicate')) continue;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: 'Failed to generate unique code' }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
invitesRouter.get('/validate/:code', async (c) => {
|
||||||
|
const code = c.req.param('code');
|
||||||
|
|
||||||
|
const [invite] = await db
|
||||||
|
.select()
|
||||||
|
.from(invitations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invitations.code, code),
|
||||||
|
isNull(invitations.usedBy),
|
||||||
|
or(
|
||||||
|
isNull(invitations.expiresAt),
|
||||||
|
sql`${invitations.expiresAt} > NOW()`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return c.json({ valid: !!invite });
|
||||||
|
});
|
||||||
|
|
||||||
|
invitesRouter.get('/remaining', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
return c.json({ remaining: -1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = parseInt(process.env.USER_INVITATIONS || '0', 10);
|
||||||
|
if (limit <= 0) {
|
||||||
|
return c.json({ remaining: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ value: created }] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(invitations)
|
||||||
|
.where(eq(invitations.createdBy, user.id));
|
||||||
|
|
||||||
|
return c.json({ remaining: Math.max(0, limit - created) });
|
||||||
|
});
|
||||||
|
|
||||||
|
invitesRouter.get('/', authMiddleware, requireAdmin, async (c) => {
|
||||||
|
const allInvites = await db
|
||||||
|
.select({
|
||||||
|
id: invitations.id,
|
||||||
|
code: invitations.code,
|
||||||
|
createdBy: invitations.createdBy,
|
||||||
|
usedBy: invitations.usedBy,
|
||||||
|
createdAt: invitations.createdAt,
|
||||||
|
expiresAt: invitations.expiresAt,
|
||||||
|
})
|
||||||
|
.from(invitations)
|
||||||
|
.orderBy(desc(invitations.createdAt));
|
||||||
|
|
||||||
|
const userIds = new Set<string>();
|
||||||
|
for (const inv of allInvites) {
|
||||||
|
userIds.add(inv.createdBy);
|
||||||
|
if (inv.usedBy) userIds.add(inv.usedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMap = new Map<string, { username: string | null; email: string | null }>();
|
||||||
|
if (userIds.size > 0) {
|
||||||
|
const userList = await db
|
||||||
|
.select({ id: users.id, username: users.username, email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.where(or(...[...userIds].map(id => eq(users.id, id))));
|
||||||
|
|
||||||
|
for (const u of userList) {
|
||||||
|
userMap.set(u.id, { username: u.username, email: u.email });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enriched = allInvites.map(inv => ({
|
||||||
|
id: inv.id,
|
||||||
|
code: inv.code,
|
||||||
|
createdBy: userMap.get(inv.createdBy) ?? { username: null, email: null },
|
||||||
|
usedBy: inv.usedBy ? (userMap.get(inv.usedBy) ?? { username: null, email: null }) : null,
|
||||||
|
createdAt: inv.createdAt,
|
||||||
|
expiresAt: inv.expiresAt,
|
||||||
|
used: !!inv.usedBy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({ invitations: enriched });
|
||||||
|
});
|
||||||
|
|
||||||
|
export { invitesRouter };
|
||||||
@@ -4,7 +4,10 @@ export type AppEnv = {
|
|||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
anonToken: string;
|
anonToken: string;
|
||||||
|
username: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
role: string;
|
||||||
|
mustResetPassword: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+36
-1
@@ -3,10 +3,30 @@ import { useGameStore } from '@/store';
|
|||||||
import { MainLayout } from '@/components/layout/MainLayout';
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
import { NewGameScreen } from '@/components/game/NewGameScreen';
|
import { NewGameScreen } from '@/components/game/NewGameScreen';
|
||||||
import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
|
import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
|
||||||
|
import { InviteGateScreen } from '@/components/game/InviteGateScreen';
|
||||||
import { useGameLoop } from '@/hooks/useGameLoop';
|
import { useGameLoop } from '@/hooks/useGameLoop';
|
||||||
|
import { useAuthGate } from '@/hooks/useAuthGate';
|
||||||
import { TICK_INTERVAL_MS } from '@ai-tycoon/shared';
|
import { TICK_INTERVAL_MS } from '@ai-tycoon/shared';
|
||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
function LoadingScreen() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 mb-4">
|
||||||
|
<Sparkles className="text-accent-light animate-pulse" size={32} />
|
||||||
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
||||||
|
AI Tycoon
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-surface-500 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
const { loading: authLoading, needsInvite, needsPasswordReset, setRegistered, setNeedsPasswordReset } = 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);
|
||||||
@@ -23,7 +43,22 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [companyName, lastTickTimestamp, catchUpDone]);
|
}, [companyName, lastTickTimestamp, catchUpDone]);
|
||||||
|
|
||||||
useGameLoop(!catchUpDone);
|
useGameLoop(!catchUpDone || authLoading || needsInvite || needsPasswordReset);
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsInvite || needsPasswordReset) {
|
||||||
|
return (
|
||||||
|
<InviteGateScreen
|
||||||
|
onRegistered={() => {
|
||||||
|
setRegistered(true);
|
||||||
|
setNeedsPasswordReset(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!companyName) {
|
if (!companyName) {
|
||||||
return <NewGameScreen />;
|
return <NewGameScreen />;
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Sparkles, ArrowLeft } from 'lucide-react';
|
||||||
|
import { api, setAuthToken, needsPasswordReset } from '@/lib/api';
|
||||||
|
|
||||||
|
type Stage = 'invite' | 'register' | 'login' | 'reset-password';
|
||||||
|
|
||||||
|
export function InviteGateScreen({ onRegistered }: { onRegistered: () => void }) {
|
||||||
|
const [stage, setStage] = useState<Stage>('invite');
|
||||||
|
const [inviteCode, setInviteCode] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [loginField, setLoginField] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get('invite');
|
||||||
|
if (code) {
|
||||||
|
setInviteCode(code);
|
||||||
|
handleValidateCode(code);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleValidateCode(code?: string) {
|
||||||
|
const codeToValidate = code || inviteCode.trim();
|
||||||
|
if (!codeToValidate) {
|
||||||
|
setError('Please enter an invite code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.invites.validate(codeToValidate);
|
||||||
|
if (result.valid) {
|
||||||
|
setInviteCode(codeToValidate);
|
||||||
|
setStage('register');
|
||||||
|
} else {
|
||||||
|
setError('Invalid or already used invite code');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Could not validate invite code');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
if (!email.trim()) { setError('Email is required'); return; }
|
||||||
|
if (password.length < 8) { setError('Password must be at least 8 characters'); return; }
|
||||||
|
if (password !== confirmPassword) { setError('Passwords do not match'); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.auth.register(email.trim(), password, inviteCode);
|
||||||
|
setAuthToken(result.token);
|
||||||
|
onRegistered();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Registration failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!loginField.trim() || !password) { setError('Email/username and password required'); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.auth.login(loginField.trim(), password);
|
||||||
|
setAuthToken(result.token);
|
||||||
|
if (needsPasswordReset()) {
|
||||||
|
setStage('reset-password');
|
||||||
|
} else {
|
||||||
|
onRegistered();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasswordReset() {
|
||||||
|
if (newPassword.length < 8) { setError('Password must be at least 8 characters'); return; }
|
||||||
|
if (newPassword !== confirmNewPassword) { setError('Passwords do not match'); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.auth.changePassword(newPassword);
|
||||||
|
setAuthToken(result.token);
|
||||||
|
onRegistered();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Password change failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center gap-2 mb-4">
|
||||||
|
<Sparkles className="text-accent-light" size={32} />
|
||||||
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
||||||
|
AI Tycoon
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-surface-400 text-sm">
|
||||||
|
{stage === 'reset-password'
|
||||||
|
? 'Please set a new password to continue.'
|
||||||
|
: 'Access is invite-only during early access.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-5">
|
||||||
|
{stage === 'invite' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-2">
|
||||||
|
Invite Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={(e) => setInviteCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleValidateCode()}
|
||||||
|
placeholder="Enter your invite code"
|
||||||
|
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 font-mono tracking-wider text-center text-lg"
|
||||||
|
autoFocus
|
||||||
|
maxLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-danger text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleValidateCode()}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Validating...' : 'Continue'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => { setStage('login'); setError(''); setPassword(''); }}
|
||||||
|
className="text-sm text-surface-400 hover:text-accent-light transition-colors"
|
||||||
|
>
|
||||||
|
Already have an account? Log in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage === 'register' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => { setStage('invite'); setError(''); }}
|
||||||
|
className="flex items-center gap-1 text-sm text-surface-400 hover:text-surface-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-xs text-surface-500 bg-surface-800 rounded px-3 py-2 font-mono">
|
||||||
|
Invite: {inviteCode}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleRegister()}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-danger text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleRegister}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating account...' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage === 'login' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => { setStage('invite'); setError(''); setPassword(''); }}
|
||||||
|
className="flex items-center gap-1 text-sm text-surface-400 hover:text-surface-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Email or Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={loginField}
|
||||||
|
onChange={(e) => setLoginField(e.target.value)}
|
||||||
|
placeholder="admin"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||||
|
placeholder="Your password"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-danger text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Logging in...' : 'Log In'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage === 'reset-password' && (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-warning bg-warning/10 border border-warning/20 rounded-lg px-3 py-2">
|
||||||
|
You must set a new password before continuing.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmNewPassword}
|
||||||
|
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handlePasswordReset()}
|
||||||
|
placeholder="Confirm your new password"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-danger text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePasswordReset}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Saving...' : 'Set Password & Continue'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-surface-600 mt-6">
|
||||||
|
Manage data centers, train models, serve millions of users, and achieve AGI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { CompetitorsPage } from '@/pages/CompetitorsPage';
|
|||||||
import { AchievementsPage } from '@/pages/AchievementsPage';
|
import { AchievementsPage } from '@/pages/AchievementsPage';
|
||||||
import { LeaderboardPage } from '@/pages/LeaderboardPage';
|
import { LeaderboardPage } from '@/pages/LeaderboardPage';
|
||||||
import { ServingPage } from '@/pages/ServingPage';
|
import { ServingPage } from '@/pages/ServingPage';
|
||||||
|
import { InvitationsPage } from '@/pages/InvitationsPage';
|
||||||
|
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const { subPath, setSubPath } = useHashRouter();
|
const { subPath, setSubPath } = useHashRouter();
|
||||||
@@ -53,6 +54,7 @@ function PageRouter({ page, subPath, setSubPath }: { page: string; subPath: stri
|
|||||||
case 'competitors': return <CompetitorsPage />;
|
case 'competitors': return <CompetitorsPage />;
|
||||||
case 'achievements': return <AchievementsPage />;
|
case 'achievements': return <AchievementsPage />;
|
||||||
case 'leaderboard': return <LeaderboardPage />;
|
case 'leaderboard': return <LeaderboardPage />;
|
||||||
|
case 'invitations': return <InvitationsPage />;
|
||||||
case 'settings': return <SettingsPage />;
|
case 'settings': return <SettingsPage />;
|
||||||
default: return <PlaceholderPage name={page} />;
|
default: return <PlaceholderPage name={page} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Server, FlaskConical, Brain,
|
LayoutDashboard, Server, FlaskConical, Brain,
|
||||||
TrendingUp, Activity, Users, Database, Swords, DollarSign, Settings, Trophy, Medal,
|
TrendingUp, Activity, Users, Database, Swords, DollarSign, Settings, Trophy, Medal,
|
||||||
PanelLeftClose, PanelLeftOpen,
|
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';
|
||||||
|
|
||||||
const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string }[] = [
|
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 },
|
||||||
{ page: 'infrastructure', label: 'Infrastructure', icon: Server },
|
{ page: 'infrastructure', label: 'Infrastructure', icon: Server },
|
||||||
{ page: 'research', label: 'Research', icon: FlaskConical },
|
{ page: 'research', label: 'Research', icon: FlaskConical },
|
||||||
@@ -19,6 +20,7 @@ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard
|
|||||||
{ page: 'competitors', label: 'Competitors', icon: Swords, era: 'scaleup' },
|
{ page: 'competitors', label: 'Competitors', icon: Swords, era: 'scaleup' },
|
||||||
{ page: 'achievements', label: 'Achievements', icon: Trophy },
|
{ page: 'achievements', label: 'Achievements', icon: Trophy },
|
||||||
{ page: 'leaderboard', label: 'Leaderboard', icon: Medal },
|
{ page: 'leaderboard', label: 'Leaderboard', icon: Medal },
|
||||||
|
{ page: 'invitations', label: 'Invitations', icon: Mail, adminOnly: true },
|
||||||
{ page: 'settings', label: 'Settings', icon: Settings },
|
{ page: 'settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -36,6 +38,11 @@ export function Sidebar() {
|
|||||||
const companyName = useGameStore((s) => s.meta.companyName);
|
const companyName = useGameStore((s) => s.meta.companyName);
|
||||||
const era = useGameStore((s) => s.meta.currentEra);
|
const era = useGameStore((s) => s.meta.currentEra);
|
||||||
const [collapsed, setCollapsed] = useState(getInitialCollapsed);
|
const [collapsed, setCollapsed] = useState(getInitialCollapsed);
|
||||||
|
const [remainingInvites, setRemainingInvites] = useState(0);
|
||||||
|
const [inviteCopied, setInviteCopied] = useState(false);
|
||||||
|
|
||||||
|
const admin = checkIsAdmin();
|
||||||
|
const registered = checkIsRegistered();
|
||||||
|
|
||||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||||
const currentEraIdx = eraOrder.indexOf(era);
|
const currentEraIdx = eraOrder.indexOf(era);
|
||||||
@@ -57,6 +64,12 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}, [era]);
|
}, [era]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!admin && registered) {
|
||||||
|
api.invites.remaining().then(r => setRemainingInvites(r.remaining)).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [admin, registered]);
|
||||||
|
|
||||||
const handleNavClick = (page: ActivePage) => {
|
const handleNavClick = (page: ActivePage) => {
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
setNewPages(prev => {
|
setNewPages(prev => {
|
||||||
@@ -74,6 +87,21 @@ export function Sidebar() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function handleInviteFriend() {
|
||||||
|
try {
|
||||||
|
const result = await api.invites.create();
|
||||||
|
const url = `${window.location.origin}?invite=${result.code}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setInviteCopied(true);
|
||||||
|
setRemainingInvites(prev => Math.max(0, prev - 1));
|
||||||
|
setTimeout(() => setInviteCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showInviteButton = !admin && registered && remainingInvites > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`${collapsed ? 'w-16' : 'w-56'} bg-surface-900 border-r border-surface-700 flex flex-col h-screen transition-all duration-200`}>
|
<aside className={`${collapsed ? 'w-16' : 'w-56'} bg-surface-900 border-r border-surface-700 flex flex-col h-screen transition-all duration-200`}>
|
||||||
<div className={`${collapsed ? 'px-2 py-3' : 'p-4'} border-b border-surface-700 flex items-center ${collapsed ? 'justify-center' : 'justify-between'}`}>
|
<div className={`${collapsed ? 'px-2 py-3' : 'p-4'} border-b border-surface-700 flex items-center ${collapsed ? 'justify-center' : 'justify-between'}`}>
|
||||||
@@ -91,12 +119,13 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 py-2 overflow-y-auto">
|
<nav className="flex-1 py-2 overflow-y-auto">
|
||||||
{NAV_ITEMS.map(({ page, label, icon: Icon, era: requiredEra }) => {
|
{NAV_ITEMS.map(({ page, label, icon: Icon, era: requiredEra, adminOnly }) => {
|
||||||
if (requiredEra && eraOrder.indexOf(requiredEra) > currentEraIdx) return null;
|
if (requiredEra && eraOrder.indexOf(requiredEra) > currentEraIdx) return null;
|
||||||
|
if (adminOnly && !admin) return null;
|
||||||
|
|
||||||
const isActive = activePage === page;
|
const isActive = activePage === page;
|
||||||
const isNew = newPages.has(page);
|
const isNew = newPages.has(page);
|
||||||
const showDivider = page === 'talent' || page === 'achievements';
|
const showDivider = page === 'talent' || page === 'achievements' || page === 'invitations';
|
||||||
return (
|
return (
|
||||||
<div key={page}>
|
<div key={page}>
|
||||||
{showDivider && <div className={`border-t border-surface-700 my-1 ${collapsed ? 'mx-2' : 'mx-4'}`} />}
|
{showDivider && <div className={`border-t border-surface-700 my-1 ${collapsed ? 'mx-2' : 'mx-4'}`} />}
|
||||||
@@ -123,6 +152,19 @@ export function Sidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{showInviteButton && (
|
||||||
|
<div className={`${collapsed ? 'px-2' : 'px-3'} pb-2`}>
|
||||||
|
<button
|
||||||
|
onClick={handleInviteFriend}
|
||||||
|
className={`w-full flex items-center ${collapsed ? 'justify-center' : 'gap-2'} px-3 py-2 text-sm rounded-lg bg-accent/10 hover:bg-accent/20 text-accent-light transition-colors`}
|
||||||
|
title={collapsed ? 'Invite a Friend' : undefined}
|
||||||
|
>
|
||||||
|
{inviteCopied ? <Check size={16} /> : <UserPlus size={16} />}
|
||||||
|
{!collapsed && (inviteCopied ? 'Link Copied!' : 'Invite a Friend')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<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 ? 'v0.1' : 'AI Tycoon v0.1'}
|
{collapsed ? 'v0.1' : 'AI Tycoon v0.1'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { api, getTokenPayload, isRegistered as checkRegistered, needsPasswordReset as checkNeedsReset, setAuthToken } from '@/lib/api';
|
||||||
|
import { ensureAuth } from './useCloudSave';
|
||||||
|
|
||||||
|
interface AuthGateState {
|
||||||
|
loading: boolean;
|
||||||
|
needsInvite: boolean;
|
||||||
|
needsPasswordReset: boolean;
|
||||||
|
registered: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
config: { requireInvite: boolean; userInvitations: number } | null;
|
||||||
|
setRegistered: (value: boolean) => void;
|
||||||
|
setNeedsPasswordReset: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthGate(): AuthGateState {
|
||||||
|
const [config, setConfig] = useState<{ requireInvite: boolean; userInvitations: number } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [registered, setRegistered] = useState(false);
|
||||||
|
const [passwordReset, setPasswordReset] = useState(false);
|
||||||
|
const [admin, setAdmin] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const [cfg] = await Promise.all([
|
||||||
|
api.config.get(),
|
||||||
|
ensureAuth(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setConfig(cfg);
|
||||||
|
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
const reg = checkRegistered();
|
||||||
|
setRegistered(reg);
|
||||||
|
setPasswordReset(checkNeedsReset());
|
||||||
|
setAdmin(payload?.role === 'admin');
|
||||||
|
} catch {
|
||||||
|
// Config fetch failed — allow game to load (fail open)
|
||||||
|
setConfig({ requireInvite: false, userInvitations: 0 });
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetRegistered = useCallback((value: boolean) => {
|
||||||
|
setRegistered(value);
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
if (payload) {
|
||||||
|
setAdmin(payload.role === 'admin');
|
||||||
|
setPasswordReset(payload.mustResetPassword);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetPasswordReset = useCallback((value: boolean) => {
|
||||||
|
setPasswordReset(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const needsInvite = !!(config?.requireInvite && !registered);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
needsInvite,
|
||||||
|
needsPasswordReset: passwordReset,
|
||||||
|
registered,
|
||||||
|
isAdmin: admin,
|
||||||
|
config,
|
||||||
|
setRegistered: handleSetRegistered,
|
||||||
|
setNeedsPasswordReset: handleSetPasswordReset,
|
||||||
|
};
|
||||||
|
}
|
||||||
+81
-5
@@ -16,6 +16,53 @@ export function clearAuthToken() {
|
|||||||
localStorage.removeItem('ai-tycoon-auth-token');
|
localStorage.removeItem('ai-tycoon-auth-token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TokenPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string | null;
|
||||||
|
role: string;
|
||||||
|
username: string | null;
|
||||||
|
mustResetPassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTokenPayload(token: string): TokenPayload | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
return {
|
||||||
|
sub: payload.sub,
|
||||||
|
email: payload.email ?? null,
|
||||||
|
role: payload.role ?? 'user',
|
||||||
|
username: payload.username ?? null,
|
||||||
|
mustResetPassword: payload.mustResetPassword ?? false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenPayload(): TokenPayload | null {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) return null;
|
||||||
|
return decodeTokenPayload(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRegistered(): boolean {
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
if (!payload) return false;
|
||||||
|
return payload.email != null || payload.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdmin(): boolean {
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
return payload?.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsPasswordReset(): boolean {
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
return payload?.mustResetPassword === true;
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -42,16 +89,45 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|||||||
export const api = {
|
export const api = {
|
||||||
auth: {
|
auth: {
|
||||||
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
|
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
|
||||||
login: (email: string, password: string) =>
|
login: (login: string, password: string) =>
|
||||||
request<{ userId: string; token: string }>('/api/auth/login', {
|
request<{ userId: string; token: string }>('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ login, password }),
|
||||||
}),
|
}),
|
||||||
linkEmail: (email: string, password: string) =>
|
register: (email: string, password: string, inviteCode: string) =>
|
||||||
request<{ success: boolean }>('/api/auth/link-email', {
|
request<{ userId: string; token: string }>('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password, inviteCode }),
|
||||||
}),
|
}),
|
||||||
|
changePassword: (newPassword: string, currentPassword?: string) =>
|
||||||
|
request<{ success: boolean; token: string }>('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ newPassword, currentPassword }),
|
||||||
|
}),
|
||||||
|
changeUsername: (username: string) =>
|
||||||
|
request<{ success: boolean; token: string }>('/api/auth/change-username', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'),
|
||||||
|
},
|
||||||
|
invites: {
|
||||||
|
create: () => request<{ code: string }>('/api/invites', { method: 'POST' }),
|
||||||
|
validate: (code: string) => request<{ valid: boolean }>(`/api/invites/validate/${encodeURIComponent(code)}`),
|
||||||
|
list: () => request<{
|
||||||
|
invitations: Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
createdBy: { username: string | null; email: string | null };
|
||||||
|
usedBy: { username: string | null; email: string | null } | null;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
used: boolean;
|
||||||
|
}>;
|
||||||
|
}>('/api/invites'),
|
||||||
|
remaining: () => request<{ remaining: number }>('/api/invites/remaining'),
|
||||||
},
|
},
|
||||||
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'),
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Copy, Check, Plus, RefreshCw } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface Invitation {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
createdBy: { username: string | null; email: string | null };
|
||||||
|
usedBy: { username: string | null; email: string | null } | null;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
used: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayUser(u: { username: string | null; email: string | null }): string {
|
||||||
|
return u.username || u.email || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ used, expiresAt }: { used: boolean; expiresAt: string | null }) {
|
||||||
|
if (used) {
|
||||||
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-surface-700 text-surface-400">Used</span>;
|
||||||
|
}
|
||||||
|
if (expiresAt && new Date(expiresAt) < new Date()) {
|
||||||
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-danger/20 text-danger">Expired</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-accent">Active</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvitationsPage() {
|
||||||
|
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchInvitations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.invites.list();
|
||||||
|
setInvitations(result.invitations);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchInvitations(); }, [fetchInvitations]);
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
setGenerating(true);
|
||||||
|
try {
|
||||||
|
const result = await api.invites.create();
|
||||||
|
const url = `${window.location.origin}?invite=${result.code}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopiedCode(result.code);
|
||||||
|
setTimeout(() => setCopiedCode(null), 2000);
|
||||||
|
fetchInvitations();
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyCode(code: string) {
|
||||||
|
const url = `${window.location.origin}?invite=${code}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopiedCode(code);
|
||||||
|
setTimeout(() => setCopiedCode(null), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCount = invitations.filter(i => !i.used && (!i.expiresAt || new Date(i.expiresAt) > new Date())).length;
|
||||||
|
const usedCount = invitations.filter(i => i.used).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">Invitations</h2>
|
||||||
|
<p className="text-sm text-surface-400 mt-1">
|
||||||
|
{activeCount} active, {usedCount} used, {invitations.length} total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={fetchInvitations}
|
||||||
|
className="px-3 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} /> Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generating}
|
||||||
|
className="px-4 py-2 rounded bg-accent hover:bg-accent-dark text-white font-medium text-sm flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> {generating ? 'Generating...' : 'Generate Invite'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-surface-500">Loading invitations...</div>
|
||||||
|
) : invitations.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-surface-500">
|
||||||
|
No invitations yet. Generate one to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-surface-700 text-left text-surface-400">
|
||||||
|
<th className="px-4 py-3 font-medium">Code</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Created By</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Used By</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Created</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 font-medium w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invitations.map((inv) => (
|
||||||
|
<tr key={inv.id} className="border-b border-surface-800 hover:bg-surface-800/50">
|
||||||
|
<td className="px-4 py-3 font-mono text-surface-200">{inv.code}</td>
|
||||||
|
<td className="px-4 py-3 text-surface-300">{displayUser(inv.createdBy)}</td>
|
||||||
|
<td className="px-4 py-3 text-surface-300">
|
||||||
|
{inv.usedBy ? displayUser(inv.usedBy) : <span className="text-surface-600">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-surface-400">
|
||||||
|
{new Date(inv.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StatusBadge used={inv.used} expiresAt={inv.expiresAt} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{!inv.used && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyCode(inv.code)}
|
||||||
|
className="text-surface-400 hover:text-surface-200 transition-colors"
|
||||||
|
title="Copy invite link"
|
||||||
|
>
|
||||||
|
{copiedCode === inv.code ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
||||||
|
import { 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);
|
||||||
@@ -63,10 +64,45 @@ export function SettingsPage() {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
const registered = isRegistered();
|
||||||
|
const admin = isAdmin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6 max-w-2xl">
|
||||||
<h2 className="text-2xl font-bold">Settings</h2>
|
<h2 className="text-2xl font-bold">Settings</h2>
|
||||||
|
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
|
||||||
|
<h3 className="font-semibold">Account</h3>
|
||||||
|
{registered ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{payload?.email && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">Email</div>
|
||||||
|
<div className="text-xs text-surface-400">{payload.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{payload?.username && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">Username</div>
|
||||||
|
<div className="text-xs text-surface-400">{payload.username}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{admin && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-accent font-medium">Admin</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-surface-400">Playing as guest.</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">
|
||||||
<h3 className="font-semibold">Game</h3>
|
<h3 className="font-semibold">Game</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
|
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
|
||||||
|
|
||||||
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
|
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
|
||||||
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'settings';
|
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'invitations' | 'settings';
|
||||||
|
|
||||||
export type InfraNavLevel = 'clusters' | 'cluster' | 'campus' | 'datacenter';
|
export type InfraNavLevel = 'clusters' | 'cluster' | 'campus' | 'datacenter';
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ services:
|
|||||||
- DATABASE_URL=postgresql://aitycoon:aitycoon@db:5432/aitycoon
|
- DATABASE_URL=postgresql://aitycoon:aitycoon@db:5432/aitycoon
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- CORS_ORIGIN=*
|
- CORS_ORIGIN=*
|
||||||
|
- JWT_SECRET=change-me-to-a-random-secret
|
||||||
|
- REQUIRE_INVITE=true
|
||||||
|
- USER_INVITATIONS=0
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
Generated
+9
@@ -26,6 +26,9 @@ importers:
|
|||||||
'@hono/node-server':
|
'@hono/node-server':
|
||||||
specifier: ^1.13.8
|
specifier: ^1.13.8
|
||||||
version: 1.19.14(hono@4.12.15)
|
version: 1.19.14(hono@4.12.15)
|
||||||
|
bcryptjs:
|
||||||
|
specifier: ^3.0.3
|
||||||
|
version: 3.0.3
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.44.2
|
specifier: ^0.44.2
|
||||||
version: 0.44.7(postgres@3.4.9)
|
version: 0.44.7(postgres@3.4.9)
|
||||||
@@ -1032,6 +1035,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
bcryptjs@3.0.3:
|
||||||
|
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
binary-extensions@2.3.0:
|
binary-extensions@2.3.0:
|
||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2438,6 +2445,8 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.10.21: {}
|
baseline-browser-mapping@2.10.21: {}
|
||||||
|
|
||||||
|
bcryptjs@3.0.3: {}
|
||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
|
|||||||
Reference in New Issue
Block a user