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

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