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:
@@ -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<Stage>('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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
||||
<div className="max-w-md w-full mx-4">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 mb-4">
|
||||
<Sparkles className="text-accent-light" size={32} />
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
||||
AI Tycoon
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-surface-400 text-sm">
|
||||
{stage === 'reset-password'
|
||||
? 'Please set a new password to continue.'
|
||||
: 'Access is invite-only during early access.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-5">
|
||||
{stage === 'invite' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-2">
|
||||
Invite Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-danger text-sm">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={() => handleValidateCode()}
|
||||
disabled={loading}
|
||||
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Validating...' : 'Continue'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => { setStage('login'); setError(''); setPassword(''); }}
|
||||
className="text-sm text-surface-400 hover:text-accent-light transition-colors"
|
||||
>
|
||||
Already have an account? Log in
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage === 'register' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setStage('invite'); setError(''); }}
|
||||
className="flex items-center gap-1 text-sm text-surface-400 hover:text-surface-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} /> Back
|
||||
</button>
|
||||
|
||||
<div className="text-xs text-surface-500 bg-surface-800 rounded px-3 py-2 font-mono">
|
||||
Invite: {inviteCode}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-danger text-sm">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleRegister}
|
||||
disabled={loading}
|
||||
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage === 'login' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setStage('invite'); setError(''); setPassword(''); }}
|
||||
className="flex items-center gap-1 text-sm text-surface-400 hover:text-surface-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} /> Back
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Email or Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={loginField}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-danger text-sm">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading}
|
||||
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Log In'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage === 'reset-password' && (
|
||||
<>
|
||||
<div className="text-sm text-warning bg-warning/10 border border-warning/20 rounded-lg px-3 py-2">
|
||||
You must set a new password before continuing.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmNewPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-danger text-sm">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handlePasswordReset}
|
||||
disabled={loading}
|
||||
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Set Password & Continue'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-surface-600 mt-6">
|
||||
Manage data centers, train models, serve millions of users, and achieve AGI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <CompetitorsPage />;
|
||||
case 'achievements': return <AchievementsPage />;
|
||||
case 'leaderboard': return <LeaderboardPage />;
|
||||
case 'invitations': return <InvitationsPage />;
|
||||
case 'settings': return <SettingsPage />;
|
||||
default: return <PlaceholderPage name={page} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user