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>
This commit is contained in:
2026-04-27 19:25:16 -04:00
parent df01ac8e35
commit 4881907c28
20 changed files with 1161 additions and 48 deletions
+79
View File
@@ -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,
};
}