4881907c28
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>
184 lines
5.0 KiB
TypeScript
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 };
|