diff --git a/packages/client/src/App.jsx b/packages/client/src/App.jsx index 2d500a9..b37b446 100644 --- a/packages/client/src/App.jsx +++ b/packages/client/src/App.jsx @@ -13,6 +13,7 @@ import { CHIPTUNE_PRESET } from './presets/chiptune.js'; import { useIsMobile } from './hooks/useIsMobile.js'; import { usePinchZoom } from './hooks/usePinchZoom.js'; import { getModulesByCategory } from './engine/moduleRegistry.js'; +import { useAuth } from './services/AuthContext.jsx'; export default function App({ onSwitchToGame }) { const [, forceUpdate] = useState(0); @@ -21,6 +22,7 @@ export default function App({ onSwitchToGame }) { const [tempWire, setTempWire] = useState(null); const connectingRef = useRef(null); const [presetModal, setPresetModal] = useState(null); + const { user, isLoggedIn, openAuth, logout } = useAuth(); const importRef = useRef(null); const isMobile = useIsMobile(); const [menuOpen, setMenuOpen] = useState(false); @@ -316,6 +318,14 @@ export default function App({ onSwitchToGame }) { {state.modules.length} modules · {state.connections.length} wires )} + {isLoggedIn ? ( +
+
{user.username?.[0]?.toUpperCase()}
+ {user.username} +
+ ) : ( + + )} {isMobile && ( )} diff --git a/packages/client/src/components/AuthModal.jsx b/packages/client/src/components/AuthModal.jsx new file mode 100644 index 0000000..e2c7d31 --- /dev/null +++ b/packages/client/src/components/AuthModal.jsx @@ -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 ( +
+
e.stopPropagation()}> +
+
~
+ Reaktor +
+ +
+ + +
+ +
+ + setEmail(e.target.value)} + required + /> + + {tab === 'register' && ( + <> + + setUsername(e.target.value)} + minLength={3} + maxLength={50} + required + /> + + )} + + + setPassword(e.target.value)} + minLength={6} + required + /> + + {error &&
{error}
} + + +
+ + + + +
+
+ ); +} diff --git a/packages/client/src/game/WorldMap.jsx b/packages/client/src/game/WorldMap.jsx index 9e21f91..ad67342 100644 --- a/packages/client/src/game/WorldMap.jsx +++ b/packages/client/src/game/WorldMap.jsx @@ -1,6 +1,7 @@ import React, { useState, useRef } from 'react'; import MobileTabBar from '../components/MobileTabBar.jsx'; import { useIsMobile } from '../hooks/useIsMobile.js'; +import { useAuth } from '../services/AuthContext.jsx'; import { WORLD_1 } from './levels/world1.js'; import { WORLD_2 } from './levels/world2.js'; import { WORLD_3 } from './levels/world3.js'; @@ -53,6 +54,7 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) { const [search, setSearch] = useState(''); const searchRef = useRef(null); const isMobile = useIsMobile(); + const { user, isLoggedIn, openAuth, logout } = useAuth(); const query = search.trim().toLowerCase(); @@ -90,6 +92,14 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) { 🛠 )} + {isLoggedIn ? ( +
+
{user.username?.[0]?.toUpperCase()}
+ {user.username} +
+ ) : ( + + )} diff --git a/packages/client/src/index.css b/packages/client/src/index.css index 1a18d20..d3014a1 100644 --- a/packages/client/src/index.css +++ b/packages/client/src/index.css @@ -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: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 ===== */ .keyboard-fullscreen { position: fixed; inset: 0; z-index: 500; diff --git a/packages/client/src/main.jsx b/packages/client/src/main.jsx index 5ad89a6..48d766c 100644 --- a/packages/client/src/main.jsx +++ b/packages/client/src/main.jsx @@ -2,16 +2,22 @@ import React, { useState } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; import GameApp from './game/GameApp.jsx'; +import { AuthProvider } from './services/AuthContext.jsx'; +import AuthModal from './components/AuthModal.jsx'; import './index.css'; function Root() { const [mode, setMode] = useState('game'); // 'game' | 'sandbox' - if (mode === 'sandbox') { - return setMode('game')} />; - } - - return setMode('sandbox')} />; + return ( + + {mode === 'sandbox' + ? setMode('game')} /> + : setMode('sandbox')} /> + } + + + ); } createRoot(document.getElementById('root')).render(); diff --git a/packages/client/src/services/AuthContext.jsx b/packages/client/src/services/AuthContext.jsx new file mode 100644 index 0000000..29a676e --- /dev/null +++ b/packages/client/src/services/AuthContext.jsx @@ -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 ( + + {children} + + ); +} diff --git a/packages/client/src/services/api.js b/packages/client/src/services/api.js new file mode 100644 index 0000000..b30f515 --- /dev/null +++ b/packages/client/src/services/api.js @@ -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), +};