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()}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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),
+};