feat: frontend auth — login/register modal + user badge

- API service (api.js): fetch wrapper with JWT, auto-refresh on 401
- AuthContext: user state, login/register/logout, loading, roles
- AuthModal: tabbed login/register form matching .pen design
- User badge in toolbar (Sandbox + WorldMap) with initial avatar
- "Entrar" button when not logged in
- CSS: auth overlay, card, tabs, inputs, error state, user badge
- Auth is opt-in: app works fully without login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 20:22:43 +01:00
parent e129fd3739
commit 3523111019
7 changed files with 399 additions and 5 deletions

View File

@@ -13,6 +13,7 @@ import { CHIPTUNE_PRESET } from './presets/chiptune.js';
import { useIsMobile } from './hooks/useIsMobile.js'; import { useIsMobile } from './hooks/useIsMobile.js';
import { usePinchZoom } from './hooks/usePinchZoom.js'; import { usePinchZoom } from './hooks/usePinchZoom.js';
import { getModulesByCategory } from './engine/moduleRegistry.js'; import { getModulesByCategory } from './engine/moduleRegistry.js';
import { useAuth } from './services/AuthContext.jsx';
export default function App({ onSwitchToGame }) { export default function App({ onSwitchToGame }) {
const [, forceUpdate] = useState(0); const [, forceUpdate] = useState(0);
@@ -21,6 +22,7 @@ export default function App({ onSwitchToGame }) {
const [tempWire, setTempWire] = useState(null); const [tempWire, setTempWire] = useState(null);
const connectingRef = useRef(null); const connectingRef = useRef(null);
const [presetModal, setPresetModal] = useState(null); const [presetModal, setPresetModal] = useState(null);
const { user, isLoggedIn, openAuth, logout } = useAuth();
const importRef = useRef(null); const importRef = useRef(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@@ -316,6 +318,14 @@ export default function App({ onSwitchToGame }) {
{state.modules.length} modules · {state.connections.length} wires {state.modules.length} modules · {state.connections.length} wires
</span> </span>
)} )}
{isLoggedIn ? (
<div className="user-badge" onClick={logout} title="Cerrar sesion">
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
<span className="user-name">{user.username}</span>
</div>
) : (
<button className="login-btn" onClick={openAuth}>Entrar</button>
)}
{isMobile && ( {isMobile && (
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}></button> <button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}></button>
)} )}

View File

@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { useAuth } from '../services/AuthContext.jsx';
export default function AuthModal() {
const { showAuth, closeAuth, login, register } = useAuth();
const [tab, setTab] = useState('login'); // 'login' | 'register'
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
if (!showAuth) return null;
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
if (tab === 'login') {
await login(email, password);
} else {
await register(email, username, password);
}
} catch (err) {
setError(err.message || 'Error');
}
setLoading(false);
};
const reset = () => {
setError('');
setEmail('');
setUsername('');
setPassword('');
};
return (
<div className="auth-overlay" onClick={closeAuth}>
<div className="auth-card" onClick={e => e.stopPropagation()}>
<div className="auth-logo">
<div className="auth-logo-box">~</div>
<span className="auth-logo-name">Reaktor</span>
</div>
<div className="auth-tabs">
<button
className={`auth-tab ${tab === 'login' ? 'active' : ''}`}
onClick={() => { setTab('login'); reset(); }}
>
Iniciar Sesion
</button>
<button
className={`auth-tab ${tab === 'register' ? 'active' : ''}`}
onClick={() => { setTab('register'); reset(); }}
>
Registrarse
</button>
</div>
<form onSubmit={handleSubmit} className="auth-form">
<label className="auth-label">EMAIL</label>
<input
type="email"
className="auth-input"
placeholder="tu@email.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
{tab === 'register' && (
<>
<label className="auth-label">USUARIO</label>
<input
type="text"
className="auth-input"
placeholder="username"
value={username}
onChange={e => setUsername(e.target.value)}
minLength={3}
maxLength={50}
required
/>
</>
)}
<label className="auth-label">CONTRASEÑA</label>
<input
type="password"
className="auth-input"
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
minLength={6}
required
/>
{error && <div className="auth-error">{error}</div>}
<button type="submit" className="auth-submit" disabled={loading}>
{loading ? '...' : tab === 'login' ? 'Entrar' : 'Crear Cuenta'}
</button>
</form>
<button className="auth-skip" onClick={closeAuth}>
Continuar sin cuenta
</button>
<button className="auth-close" onClick={closeAuth}></button>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import MobileTabBar from '../components/MobileTabBar.jsx'; import MobileTabBar from '../components/MobileTabBar.jsx';
import { useIsMobile } from '../hooks/useIsMobile.js'; import { useIsMobile } from '../hooks/useIsMobile.js';
import { useAuth } from '../services/AuthContext.jsx';
import { WORLD_1 } from './levels/world1.js'; import { WORLD_1 } from './levels/world1.js';
import { WORLD_2 } from './levels/world2.js'; import { WORLD_2 } from './levels/world2.js';
import { WORLD_3 } from './levels/world3.js'; import { WORLD_3 } from './levels/world3.js';
@@ -53,6 +54,7 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const searchRef = useRef(null); const searchRef = useRef(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { user, isLoggedIn, openAuth, logout } = useAuth();
const query = search.trim().toLowerCase(); const query = search.trim().toLowerCase();
@@ -90,6 +92,14 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
🛠 🛠
</button> </button>
)} )}
{isLoggedIn ? (
<div className="user-badge" onClick={logout} title="Cerrar sesion">
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
<span className="user-name">{user.username}</span>
</div>
) : (
<button className="login-btn" onClick={openAuth}>Entrar</button>
)}
</div> </div>
</div> </div>

View File

@@ -801,6 +801,103 @@ input, textarea, [contenteditable] { -webkit-user-select: text; user-select: tex
.admin-star-btn.zero { color: var(--red); } .admin-star-btn.zero { color: var(--red); }
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); } .admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }
/* ===== Auth Modal ===== */
.auth-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.75);
display: flex; align-items: center; justify-content: center;
z-index: 600; animation: fadeIn 0.2s;
}
.auth-card {
width: 400px; max-width: calc(100% - 32px); background: var(--panel);
border: 1px solid var(--border); border-radius: 16px;
padding: 32px; display: flex; flex-direction: column; gap: 20px;
align-items: center; position: relative;
}
.auth-logo { display: flex; align-items: center; gap: 10px; }
.auth-logo-box {
width: 40px; height: 40px; background: var(--surface); border-radius: 8px;
border: 1px solid var(--accent); display: flex; align-items: center;
justify-content: center; font-size: 22px; font-weight: 700; color: var(--accent);
}
.auth-logo-name { font-size: 22px; font-weight: 700; color: var(--text); }
.auth-tabs {
display: flex; width: 100%; background: var(--surface); border-radius: 8px;
padding: 4px; gap: 4px;
}
.auth-tab {
flex: 1; padding: 10px 0; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 600; font-family: inherit; text-align: center;
background: transparent; color: var(--text2); transition: all 0.15s;
}
.auth-tab.active { background: var(--accent); color: #000; }
.auth-form {
display: flex; flex-direction: column; gap: 12px; width: 100%;
}
.auth-label {
font-size: 10px; font-weight: 700; color: var(--text2);
letter-spacing: 1px; text-transform: uppercase;
}
.auth-input {
width: 100%; padding: 12px 14px; background: var(--bg);
border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-size: 14px; font-family: inherit;
-webkit-user-select: text; user-select: text;
}
.auth-input:focus { outline: none; border-color: var(--accent); }
.auth-submit {
width: 100%; padding: 14px 0; background: var(--accent);
border: none; border-radius: 8px; color: #000;
font-size: 14px; font-weight: 700; cursor: pointer;
font-family: inherit; transition: opacity 0.15s;
}
.auth-submit:hover { opacity: 0.9; }
.auth-submit:disabled { opacity: 0.5; cursor: not-allowed; }
.auth-error {
padding: 8px 12px; background: rgba(255,68,102,0.1);
border: 1px solid var(--red); border-radius: 6px;
color: var(--red); font-size: 12px; text-align: center;
}
.auth-skip {
background: none; border: none; color: var(--text2);
font-size: 13px; cursor: pointer; font-family: inherit;
padding: 8px;
}
.auth-skip:hover { color: var(--text); }
.auth-close {
position: absolute; top: 12px; right: 12px;
width: 32px; height: 32px; border-radius: 16px;
background: var(--surface); border: 1px solid var(--border);
color: var(--text); font-size: 14px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
/* User badge in toolbar */
.user-badge {
display: flex; align-items: center; gap: 6px; cursor: pointer;
padding: 4px 10px; border-radius: 6px; background: var(--surface);
border: 1px solid var(--border);
}
.user-badge:hover { border-color: var(--accent); }
.user-avatar {
width: 22px; height: 22px; border-radius: 11px;
background: var(--accent); display: flex; align-items: center;
justify-content: center; font-size: 10px; font-weight: 700; color: #000;
}
.user-name { font-size: 11px; font-weight: 600; color: var(--text); }
.login-btn {
padding: 4px 12px; border: 1px solid var(--accent); border-radius: 6px;
background: transparent; color: var(--accent); cursor: pointer;
font-size: 11px; font-weight: 600; font-family: inherit;
}
.login-btn:hover { background: var(--accent); color: #000; }
/* ===== Fullscreen Keyboard ===== */ /* ===== Fullscreen Keyboard ===== */
.keyboard-fullscreen { .keyboard-fullscreen {
position: fixed; inset: 0; z-index: 500; position: fixed; inset: 0; z-index: 500;

View File

@@ -2,16 +2,22 @@ import React, { useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App'; import App from './App';
import GameApp from './game/GameApp.jsx'; import GameApp from './game/GameApp.jsx';
import { AuthProvider } from './services/AuthContext.jsx';
import AuthModal from './components/AuthModal.jsx';
import './index.css'; import './index.css';
function Root() { function Root() {
const [mode, setMode] = useState('game'); // 'game' | 'sandbox' const [mode, setMode] = useState('game'); // 'game' | 'sandbox'
if (mode === 'sandbox') { return (
return <App onSwitchToGame={() => setMode('game')} />; <AuthProvider>
{mode === 'sandbox'
? <App onSwitchToGame={() => setMode('game')} />
: <GameApp onSwitchToSandbox={() => setMode('sandbox')} />
} }
<AuthModal />
return <GameApp onSwitchToSandbox={() => setMode('sandbox')} />; </AuthProvider>
);
} }
createRoot(document.getElementById('root')).render(<Root />); createRoot(document.getElementById('root')).render(<Root />);

View File

@@ -0,0 +1,74 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { auth as authApi, users as usersApi, setAccessToken, setOnUnauthorized } from './api.js';
const AuthContext = createContext(null);
export function useAuth() {
return useContext(AuthContext);
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [showAuth, setShowAuth] = useState(false);
const logout = useCallback(async () => {
try { await authApi.logout(); } catch {}
setAccessToken(null);
setUser(null);
}, []);
// On mount, try to refresh session
useEffect(() => {
setOnUnauthorized(() => {
setUser(null);
setAccessToken(null);
});
authApi.refresh().then(async (ok) => {
if (ok) {
try {
const me = await usersApi.me();
setUser(me);
} catch {}
}
setLoading(false);
});
}, []);
const login = useCallback(async (email, password) => {
const data = await authApi.login(email, password);
setAccessToken(data.accessToken);
setUser(data.user);
setShowAuth(false);
return data.user;
}, []);
const register = useCallback(async (email, username, password) => {
const data = await authApi.register(email, username, password);
setAccessToken(data.accessToken);
setUser(data.user);
setShowAuth(false);
return data.user;
}, []);
const value = {
user,
loading,
isLoggedIn: !!user,
isAdmin: user?.role === 'admin',
isPremium: user?.role === 'premium' || user?.role === 'admin',
login,
register,
logout,
showAuth,
openAuth: () => setShowAuth(true),
closeAuth: () => setShowAuth(false),
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,83 @@
const API_BASE = '/api/v1';
let _accessToken = null;
let _onUnauthorized = null;
export function setAccessToken(token) { _accessToken = token; }
export function getAccessToken() { return _accessToken; }
export function setOnUnauthorized(fn) { _onUnauthorized = fn; }
async function request(method, path, body = null, opts = {}) {
const headers = { 'Content-Type': 'application/json' };
if (_accessToken) headers['Authorization'] = `Bearer ${_accessToken}`;
const res = await fetch(`${API_BASE}${path}`, {
method,
headers,
credentials: 'include', // send cookies (refresh token)
body: body ? JSON.stringify(body) : null,
...opts,
});
if (res.status === 401 && !path.includes('/auth/')) {
// Try to refresh
const refreshed = await refreshToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${_accessToken}`;
const retry = await fetch(`${API_BASE}${path}`, {
method, headers, credentials: 'include',
body: body ? JSON.stringify(body) : null,
});
if (retry.ok) return retry.json();
}
_onUnauthorized?.();
throw new Error('Unauthorized');
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
return res.json();
}
async function refreshToken() {
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return false;
const data = await res.json();
_accessToken = data.accessToken;
return true;
} catch {
return false;
}
}
// Auth
export const auth = {
register: (email, username, password) =>
request('POST', '/auth/register', { email, username, password }),
login: (email, password) =>
request('POST', '/auth/login', { email, password }),
logout: () => request('POST', '/auth/logout'),
refresh: refreshToken,
};
// Users
export const users = {
me: () => request('GET', '/users/me'),
update: (data) => request('PATCH', '/users/me', data),
};
// Admin
export const admin = {
stats: () => request('GET', '/admin/stats'),
users: (params = '') => request('GET', `/admin/users${params ? '?' + params : ''}`),
updateUser: (id, data) => request('PATCH', `/admin/users/${id}`, data),
patches: (params = '') => request('GET', `/admin/patches${params ? '?' + params : ''}`),
updatePatch: (id, data) => request('PATCH', `/admin/patches/${id}`, data),
};