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:
83
packages/client/src/services/api.js
Normal file
83
packages/client/src/services/api.js
Normal 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),
|
||||
};
|
||||
Reference in New Issue
Block a user