diff --git a/apps/server/package.json b/apps/server/package.json index 6e19c9d..4888983 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,6 +15,7 @@ "dependencies": { "@ai-tycoon/shared": "workspace:*", "@hono/node-server": "^1.13.8", + "bcryptjs": "^3.0.3", "drizzle-orm": "^0.44.2", "hono": "^4.7.10", "postgres": "^3.4.7", diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 016e22b..10003f2 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -3,8 +3,11 @@ import { pgTable, uuid, text, timestamp, jsonb, integer, boolean, index } from ' export const users = pgTable('users', { id: uuid('id').defaultRandom().primaryKey(), anonToken: uuid('anon_token').defaultRandom().notNull().unique(), + username: text('username').unique(), email: text('email').unique(), passwordHash: text('password_hash'), + role: text('role').notNull().default('user'), + mustResetPassword: boolean('must_reset_password').notNull().default(false), createdAt: timestamp('created_at').defaultNow().notNull(), lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(), }); @@ -44,3 +47,12 @@ export const achievements = pgTable('achievements', { }, (table) => [ 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'), +}); diff --git a/apps/server/src/db/seed.ts b/apps/server/src/db/seed.ts new file mode 100644 index 0000000..194dbc9 --- /dev/null +++ b/apps/server/src/db/seed.ts @@ -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)'); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 8af8ace..7bdfb60 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -5,6 +5,13 @@ import { serve } from '@hono/node-server'; import { auth } from './routes/auth'; import { savesRouter } from './routes/saves'; 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(); @@ -19,12 +26,20 @@ app.use('*', cors({ 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/saves', savesRouter); app.route('/api/leaderboard', leaderboardRouter); +app.route('/api/invites', invitesRouter); const port = Number(process.env.PORT) || 3001; console.log(`AI Tycoon API server starting on port ${port}...`); +await seedAdmin(); + serve({ fetch: app.fetch, port }); diff --git a/apps/server/src/lib/jwt.ts b/apps/server/src/lib/jwt.ts new file mode 100644 index 0000000..695ab65 --- /dev/null +++ b/apps/server/src/lib/jwt.ts @@ -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 { + 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, + }; +} diff --git a/apps/server/src/middleware/auth.ts b/apps/server/src/middleware/auth.ts index e01917d..19b83ad 100644 --- a/apps/server/src/middleware/auth.ts +++ b/apps/server/src/middleware/auth.ts @@ -2,6 +2,7 @@ import { createMiddleware } from 'hono/factory'; import { eq } from 'drizzle-orm'; import { db } from '../db'; import { users } from '../db/schema'; +import { verifyToken } from '../lib/jwt'; import type { AppEnv } from '../types'; export const authMiddleware = createMiddleware(async (c, next) => { @@ -14,10 +15,12 @@ export const authMiddleware = createMiddleware(async (c, next) => { const token = authHeader.slice(7); try { + const payload = await verifyToken(token); + const [user] = await db .select() .from(users) - .where(eq(users.anonToken, token)) + .where(eq(users.id, payload.sub)) .limit(1); if (!user) { @@ -30,9 +33,24 @@ export const authMiddleware = createMiddleware(async (c, next) => { .where(eq(users.id, 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(); } catch { - return c.json({ error: 'Authentication failed' }, 500); + return c.json({ error: 'Invalid or expired token' }, 401); } }); + +export const requireAdmin = createMiddleware(async (c, next) => { + const user = c.get('user'); + if (user.role !== 'admin') { + return c.json({ error: 'Forbidden' }, 403); + } + await next(); +}); diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index c56c5b3..e23f7d9 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -1,7 +1,10 @@ import { Hono } from 'hono'; -import { eq } from 'drizzle-orm'; +import { eq, or } from 'drizzle-orm'; +import bcrypt from 'bcryptjs'; import { db } from '../db'; import { users } from '../db/schema'; +import { createToken } from '../lib/jwt'; +import { authMiddleware } from '../middleware/auth'; import type { AppEnv } from '../types'; const auth = new Hono(); @@ -12,20 +15,51 @@ auth.post('/anonymous', async (c) => { .values({}) .returning(); - return c.json({ - userId: user.id, - token: user.anonToken, - }); + const token = await createToken(user.id, null, 'user', null, false); + return c.json({ userId: user.id, token }); }); -auth.post('/link-email', async (c) => { - const userId = c.get('userId') as string; - if (!userId) return c.json({ error: 'Not authenticated' }, 401); +auth.post('/register', authMiddleware, async (c) => { + const userId = c.get('userId'); + 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) { - return c.json({ error: 'Email and password required' }, 400); + if (process.env.REQUIRE_INVITE !== 'false') { + 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 @@ -38,45 +72,112 @@ auth.post('/link-email', async (c) => { return c.json({ error: 'Email already in use' }, 409); } - const encoder = new TextEncoder(); - 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(''); + const passwordHash = await bcrypt.hash(password, 10); - await db + const [updated] = await db .update(users) - .set({ email, passwordHash: hashHex }) - .where(eq(users.id, userId)); + .set({ email, passwordHash }) + .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) => { - 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(); - 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(''); + if (!login || !password) { + return c.json({ error: 'Login and password required' }, 400); + } const [user] = await db .select() .from(users) - .where(eq(users.email, email)) + .where(or(eq(users.email, login), eq(users.username, login))) .limit(1); - if (!user || user.passwordHash !== hashHex) { + if (!user || !user.passwordHash) { return c.json({ error: 'Invalid credentials' }, 401); } - return c.json({ - userId: user.id, - token: user.anonToken, - }); + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + 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 }; diff --git a/apps/server/src/routes/invites.ts b/apps/server/src/routes/invites.ts new file mode 100644 index 0000000..a87aa20 --- /dev/null +++ b/apps/server/src/routes/invites.ts @@ -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(); + +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(); + for (const inv of allInvites) { + userIds.add(inv.createdBy); + if (inv.usedBy) userIds.add(inv.usedBy); + } + + const userMap = new Map(); + 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 }; diff --git a/apps/server/src/types.ts b/apps/server/src/types.ts index 9757258..41075b0 100644 --- a/apps/server/src/types.ts +++ b/apps/server/src/types.ts @@ -4,7 +4,10 @@ export type AppEnv = { user: { id: string; anonToken: string; + username: string | null; email: string | null; + role: string; + mustResetPassword: boolean; }; }; }; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2f2d0bb..fc21f0b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,10 +3,30 @@ import { useGameStore } from '@/store'; import { MainLayout } from '@/components/layout/MainLayout'; import { NewGameScreen } from '@/components/game/NewGameScreen'; import { OfflineCatchUp } from '@/components/game/OfflineCatchUp'; +import { InviteGateScreen } from '@/components/game/InviteGateScreen'; import { useGameLoop } from '@/hooks/useGameLoop'; +import { useAuthGate } from '@/hooks/useAuthGate'; import { TICK_INTERVAL_MS } from '@ai-tycoon/shared'; +import { Sparkles } from 'lucide-react'; + +function LoadingScreen() { + return ( +
+
+
+ +

+ AI Tycoon +

+
+

Loading...

+
+
+ ); +} export function App() { + const { loading: authLoading, needsInvite, needsPasswordReset, setRegistered, setNeedsPasswordReset } = useAuthGate(); const companyName = useGameStore((s) => s.meta.companyName); const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp); const [catchUpTicks, setCatchUpTicks] = useState(null); @@ -23,7 +43,22 @@ export function App() { } }, [companyName, lastTickTimestamp, catchUpDone]); - useGameLoop(!catchUpDone); + useGameLoop(!catchUpDone || authLoading || needsInvite || needsPasswordReset); + + if (authLoading) { + return ; + } + + if (needsInvite || needsPasswordReset) { + return ( + { + setRegistered(true); + setNeedsPasswordReset(false); + }} + /> + ); + } if (!companyName) { return ; diff --git a/apps/web/src/components/game/InviteGateScreen.tsx b/apps/web/src/components/game/InviteGateScreen.tsx new file mode 100644 index 0000000..8568d81 --- /dev/null +++ b/apps/web/src/components/game/InviteGateScreen.tsx @@ -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('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 ( +
+
+
+
+ +

+ AI Tycoon +

+
+

+ {stage === 'reset-password' + ? 'Please set a new password to continue.' + : 'Access is invite-only during early access.'} +

+
+ +
+ {stage === 'invite' && ( + <> +
+ + 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} + /> +
+ + {error &&

{error}

} + + + +
+ +
+ + )} + + {stage === 'register' && ( + <> + + +
+ Invite: {inviteCode} +
+ +
+ + 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 + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + {error &&

{error}

} + + + + )} + + {stage === 'login' && ( + <> + + +
+ + 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 + /> +
+ +
+ + 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" + /> +
+ + {error &&

{error}

} + + + + )} + + {stage === 'reset-password' && ( + <> +
+ You must set a new password before continuing. +
+ +
+ + 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 + /> +
+ +
+ + 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" + /> +
+ + {error &&

{error}

} + + + + )} +
+ +

+ Manage data centers, train models, serve millions of users, and achieve AGI. +

+
+
+ ); +} diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 2435a3f..570511c 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -18,6 +18,7 @@ import { CompetitorsPage } from '@/pages/CompetitorsPage'; import { AchievementsPage } from '@/pages/AchievementsPage'; import { LeaderboardPage } from '@/pages/LeaderboardPage'; import { ServingPage } from '@/pages/ServingPage'; +import { InvitationsPage } from '@/pages/InvitationsPage'; export function MainLayout() { const { subPath, setSubPath } = useHashRouter(); @@ -53,6 +54,7 @@ function PageRouter({ page, subPath, setSubPath }: { page: string; subPath: stri case 'competitors': return ; case 'achievements': return ; case 'leaderboard': return ; + case 'invitations': return ; case 'settings': return ; default: return ; } diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 150a1e1..f7e86d6 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -2,11 +2,12 @@ import { useState, useEffect, useRef } from 'react'; import { LayoutDashboard, Server, FlaskConical, Brain, TrendingUp, Activity, Users, Database, Swords, DollarSign, Settings, Trophy, Medal, - PanelLeftClose, PanelLeftOpen, + PanelLeftClose, PanelLeftOpen, Mail, UserPlus, Copy, Check, } from 'lucide-react'; import { useGameStore, type ActivePage } from '@/store'; +import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, api } from '@/lib/api'; -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: 'infrastructure', label: 'Infrastructure', icon: Server }, { 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: 'achievements', label: 'Achievements', icon: Trophy }, { page: 'leaderboard', label: 'Leaderboard', icon: Medal }, + { page: 'invitations', label: 'Invitations', icon: Mail, adminOnly: true }, { page: 'settings', label: 'Settings', icon: Settings }, ]; @@ -36,6 +38,11 @@ export function Sidebar() { const companyName = useGameStore((s) => s.meta.companyName); const era = useGameStore((s) => s.meta.currentEra); 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 currentEraIdx = eraOrder.indexOf(era); @@ -57,6 +64,12 @@ export function Sidebar() { } }, [era]); + useEffect(() => { + if (!admin && registered) { + api.invites.remaining().then(r => setRemainingInvites(r.remaining)).catch(() => {}); + } + }, [admin, registered]); + const handleNavClick = (page: ActivePage) => { setActivePage(page); 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 (