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
+46 -4
View File
@@ -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 (
<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'}`}>
@@ -91,12 +119,13 @@ export function Sidebar() {
</div>
<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 (adminOnly && !admin) return null;
const isActive = activePage === page;
const isNew = newPages.has(page);
const showDivider = page === 'talent' || page === 'achievements';
const showDivider = page === 'talent' || page === 'achievements' || page === 'invitations';
return (
<div key={page}>
{showDivider && <div className={`border-t border-surface-700 my-1 ${collapsed ? 'mx-2' : 'mx-4'}`} />}
@@ -123,6 +152,19 @@ export function Sidebar() {
})}
</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`}>
{collapsed ? 'v0.1' : 'AI Tycoon v0.1'}
</div>