Files
AIHostingTycoon/apps/server/src/routes/auth.ts
T
josh 4881907c28 Add auth system with invite-only registration and admin roles
JWT-based auth (hono/jwt + bcrypt), anonymous-first flow preserved.
Registration requires invite code when REQUIRE_INVITE=true. Admin
user seeded on startup (admin/admin, forced password reset). Login
accepts email or username. Admin invitations management page in
sidebar. Regular users get invite-a-friend button when USER_INVITATIONS > 0.
Frontend gate screen blocks game access for unregistered users with
invite code entry, registration, login, and password reset flows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 19:25:16 -04:00

184 lines
5.0 KiB
TypeScript

import { Hono } from 'hono';
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<AppEnv>();
auth.post('/anonymous', async (c) => {
const [user] = await db
.insert(users)
.values({})
.returning();
const token = await createToken(user.id, null, 'user', null, false);
return c.json({ userId: user.id, token });
});
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;
}>();
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 (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
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existing.length > 0) {
return c.json({ error: 'Email already in use' }, 409);
}
const passwordHash = await bcrypt.hash(password, 10);
const [updated] = await db
.update(users)
.set({ email, passwordHash })
.where(eq(users.id, userId))
.returning();
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 { login, password } = await c.req.json<{ login: string; password: string }>();
if (!login || !password) {
return c.json({ error: 'Login and password required' }, 400);
}
const [user] = await db
.select()
.from(users)
.where(or(eq(users.email, login), eq(users.username, login)))
.limit(1);
if (!user || !user.passwordHash) {
return c.json({ error: 'Invalid credentials' }, 401);
}
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 };