Răsfoiți Sursa

start the connections

gdias 2 săptămâni în urmă
părinte
comite
4965d7bb9c

+ 3 - 0
.env.example

@@ -0,0 +1,3 @@
+# URL base da API backend (sem barra no final).
+# Em dev, o backend PHP roda em http://localhost:8080 (APP_PORT no backend).
+VITE_API_BASE_URL=http://localhost:8080

+ 88 - 0
src/lib/core/api/client.js

@@ -0,0 +1,88 @@
+// Client HTTP central da aplicação.
+//
+// Responsabilidades:
+//  - montar a URL com query params (ignorando valores vazios);
+//  - injetar o header `Authorization: Bearer <jwt>` nas chamadas privadas;
+//  - desempacotar o envelope padrão do backend ({ status, code, message, data });
+//  - lançar `ApiError` em falhas (incluindo erro de rede);
+//  - tratar 401 globalmente: limpa o token e redireciona ao login.
+import { browser } from '$app/environment';
+import { goto } from '$app/navigation';
+import { API_BASE_URL } from './config.js';
+import { getToken, clearToken } from './token.js';
+
+/** Erro normalizado de API. `code` segue a tabela do backend (E_VALIDATE, etc.). */
+export class ApiError extends Error {
+	constructor(message, code = 'E_GENERIC', httpStatus = 0, data = null) {
+		super(message);
+		this.name = 'ApiError';
+		this.code = code;
+		this.httpStatus = httpStatus;
+		this.data = data;
+	}
+}
+
+function buildUrl(path, query) {
+	const url = new URL(`${API_BASE_URL}${path}`);
+	if (query) {
+		for (const [key, value] of Object.entries(query)) {
+			if (value === undefined || value === null || value === '') continue;
+			url.searchParams.set(key, String(value));
+		}
+	}
+	return url.toString();
+}
+
+async function request(method, path, { query, body, auth = true } = {}) {
+	const headers = { 'Content-Type': 'application/json' };
+
+	if (auth) {
+		const token = getToken();
+		if (token) headers.Authorization = `Bearer ${token}`;
+	}
+
+	let response;
+	try {
+		response = await fetch(buildUrl(path, query), {
+			method,
+			headers,
+			body: body !== undefined ? JSON.stringify(body) : undefined
+		});
+	} catch {
+		throw new ApiError('Falha de conexão com o servidor.', 'E_NETWORK', 0, null);
+	}
+
+	// Tratamento global de sessão expirada/ inválida.
+	if (response.status === 401) {
+		clearToken();
+		if (browser) goto('/login');
+		throw new ApiError('Sessão expirada. Faça login novamente.', 'E_UNAUTHORIZED', 401, null);
+	}
+
+	// O backend omite `data` quando vazio; algumas respostas podem não ser JSON.
+	let envelope = null;
+	try {
+		envelope = await response.json();
+	} catch {
+		envelope = null;
+	}
+
+	if (!envelope || envelope.status === 'failed') {
+		throw new ApiError(
+			envelope?.message ?? 'Erro inesperado ao comunicar com o servidor.',
+			envelope?.code ?? 'E_GENERIC',
+			response.status,
+			envelope?.data ?? null
+		);
+	}
+
+	return envelope.data ?? {};
+}
+
+export const api = {
+	get: (path, options) => request('GET', path, options),
+	post: (path, options) => request('POST', path, options),
+	put: (path, options) => request('PUT', path, options),
+	patch: (path, options) => request('PATCH', path, options),
+	delete: (path, options) => request('DELETE', path, options)
+};

+ 7 - 0
src/lib/core/api/config.js

@@ -0,0 +1,7 @@
+// URL base da API, configurável por ambiente.
+//
+// Defina `VITE_API_BASE_URL` em um arquivo `.env` (ver `.env.example`).
+// Em dev, o padrão aponta para o servidor PHP local (APP_PORT=8080).
+//
+// A barra final é removida para evitar `//v1/...` ao concatenar os paths.
+export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080').replace(/\/+$/, '');

+ 23 - 0
src/lib/core/api/token.js

@@ -0,0 +1,23 @@
+// Persistência do JWT no localStorage.
+//
+// Mantido isolado (sem depender do client nem do store de auth) para evitar
+// importações circulares: o client lê o token aqui, e o store de auth grava aqui.
+import { browser } from '$app/environment';
+
+const TOKEN_KEY = 'nettown_token';
+
+export function getToken() {
+	return browser ? localStorage.getItem(TOKEN_KEY) : null;
+}
+
+export function setToken(token) {
+	if (browser && token) {
+		localStorage.setItem(TOKEN_KEY, token);
+	}
+}
+
+export function clearToken() {
+	if (browser) {
+		localStorage.removeItem(TOKEN_KEY);
+	}
+}

+ 85 - 0
src/lib/core/stores/auth.js

@@ -0,0 +1,85 @@
+// Store de autenticação: token + perfil do usuário, com persistência.
+//
+// Fluxo:
+//  - `login(email, password)`  → POST /v1/login, grava token + user;
+//  - `loadMe()`                → GET /v1/me para reidratar a sessão no refresh;
+//  - `logout()`                → limpa token e user.
+//
+// O perfil é guardado no localStorage só para evitar "flash" sem nome no header
+// durante o reload; a fonte de verdade continua sendo o /v1/me.
+import { writable, derived } from 'svelte/store';
+import { browser } from '$app/environment';
+import { api } from '../api/client.js';
+import { getToken, setToken, clearToken } from '../api/token.js';
+
+const USER_KEY = 'nettown_user';
+
+function readStoredUser() {
+	if (!browser) return null;
+	try {
+		const raw = localStorage.getItem(USER_KEY);
+		return raw ? JSON.parse(raw) : null;
+	} catch {
+		return null;
+	}
+}
+
+function persistUser(user) {
+	if (!browser) return;
+	if (user) localStorage.setItem(USER_KEY, JSON.stringify(user));
+	else localStorage.removeItem(USER_KEY);
+}
+
+function createAuthStore() {
+	const { subscribe, set } = writable({
+		user: readStoredUser(),
+		token: getToken(),
+		// `initialized` indica que já tentamos reidratar a sessão (evita piscar o guard).
+		initialized: false
+	});
+
+	return {
+		subscribe,
+
+		/** Autentica e persiste a sessão. Retorna o user em caso de sucesso. */
+		async login(email, password) {
+			const data = await api.post('/v1/login', { body: { email, password }, auth: false });
+			setToken(data.token);
+			persistUser(data.user);
+			set({ user: data.user, token: data.token, initialized: true });
+			return data.user;
+		},
+
+		/** Reidrata a sessão a partir do token salvo. Retorna o perfil ou null. */
+		async loadMe() {
+			const token = getToken();
+			if (!token) {
+				set({ user: null, token: null, initialized: true });
+				return null;
+			}
+			try {
+				const profile = await api.get('/v1/me');
+				persistUser(profile);
+				set({ user: profile, token, initialized: true });
+				return profile;
+			} catch {
+				// 401 já limpou o token no client; aqui só normalizamos o estado.
+				persistUser(null);
+				set({ user: null, token: null, initialized: true });
+				return null;
+			}
+		},
+
+		/** Encerra a sessão local. */
+		logout() {
+			clearToken();
+			persistUser(null);
+			set({ user: null, token: null, initialized: true });
+		}
+	};
+}
+
+export const auth = createAuthStore();
+
+/** `true` quando há token (sessão presumivelmente ativa). */
+export const isAuthenticated = derived(auth, ($auth) => Boolean($auth.token));

+ 31 - 6
src/routes/(app)/+layout.svelte

@@ -27,9 +27,20 @@
 	import logoWhite from '$lib/assets/images/nettown_white_logo.svg';
 	import logoBlack from '$lib/assets/images/nettown_black_logo.svg.svg';
 	import { theme, toggleTheme } from '$lib/core/stores/theme';
+	import { auth } from '$lib/core/stores/auth';
 	import { onMount } from 'svelte';
 
 	let { children } = $props();
+
+	/** Iniciais para o avatar a partir do nome do usuário. */
+	function getInitials(name) {
+		const parts = (name ?? '').trim().split(/\s+/).filter(Boolean);
+		if (parts.length === 0) return 'NT';
+		return parts
+			.slice(0, 2)
+			.map((p) => p[0].toUpperCase())
+			.join('');
+	}
 	let isMobileMenuOpen = $state(false);
 	let isSidebarCollapsed = $state(false);
 	let currentTheme = $state('light');
@@ -39,6 +50,13 @@
 		theme.subscribe((val) => {
 			currentTheme = val;
 		});
+
+		// Guard de sessão: reidrata o perfil via /v1/me. Sem sessão válida,
+		// redireciona ao login. (Um 401 em qualquer chamada já limpa o token
+		// e redireciona via client HTTP.)
+		auth.loadMe().then((profile) => {
+			if (!profile) goto('/login');
+		});
 	});
 
 	const navItems = [
@@ -54,7 +72,8 @@
 	];
 
 	function handleLogout() {
-		goto('/');
+		auth.logout();
+		goto('/login');
 	}
 
 	function toggleMobileMenu() {
@@ -260,8 +279,10 @@
 				<div class="mx-1 h-8 w-px bg-slate-200 dark:bg-slate-700"></div>
 				<div class="relative flex items-center gap-3">
 					<div class="hidden text-right sm:block">
-						<div class="text-sm font-medium text-slate-900 dark:text-white">Admin</div>
-						<div class="text-xs text-slate-500">Gestor de Vendas</div>
+						<div class="text-sm font-medium text-slate-900 dark:text-white">
+							{$auth.user?.user_name ?? 'Usuário'}
+						</div>
+						<div class="text-xs text-slate-500 capitalize">{$auth.user?.user_role ?? ''}</div>
 					</div>
 					<div class="relative">
 						<button
@@ -269,7 +290,7 @@
 							class="flex h-9 w-9 items-center justify-center rounded-full bg-indigo-600 text-sm font-bold text-white shadow-sm shadow-indigo-900/20 transition-colors hover:bg-indigo-700"
 							title="Menu do usuário"
 						>
-							AD
+							{getInitials($auth.user?.user_name)}
 						</button>
 
 						<!-- User Dropdown Menu -->
@@ -278,8 +299,12 @@
 								class="absolute right-0 z-50 mt-2 w-56 rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-800 dark:bg-[#1e293b]"
 							>
 								<div class="border-b border-slate-200 px-4 py-3 dark:border-slate-800">
-									<p class="text-sm font-medium text-slate-900 dark:text-white">Admin</p>
-									<p class="text-xs text-slate-500 dark:text-slate-400">admin@nettown.com</p>
+									<p class="text-sm font-medium text-slate-900 dark:text-white">
+										{$auth.user?.user_name ?? 'Usuário'}
+									</p>
+									<p class="text-xs text-slate-500 dark:text-slate-400">
+										{$auth.user?.user_email ?? ''}
+									</p>
 								</div>
 
 								<a

+ 15 - 8
src/routes/(public)/login/+page.svelte

@@ -3,6 +3,7 @@
     import { goto } from '$app/navigation';
     import { onMount } from 'svelte';
     import { theme, toggleTheme } from '$lib/core/stores/theme';
+    import { auth } from '$lib/core/stores/auth';
     import logoWhite from '$lib/assets/images/nettown_white_logo.svg';
     import logoBlack from '$lib/assets/images/nettown_black_logo.svg.svg';
 
@@ -24,15 +25,21 @@
         isLoading = true;
         error = '';
 
-        // Mock login
-        setTimeout(() => {
-            if (email === 'admin@nettown.com' && password === 'admin') {
-                goto('/dashboard');
+        try {
+            await auth.login(email, password);
+            await goto('/dashboard');
+        } catch (err) {
+            if (err?.httpStatus === 401) {
+                error = 'E-mail ou senha inválidos.';
+            } else if (err?.code === 'E_RATE_LIMIT') {
+                error = 'Muitas tentativas. Aguarde alguns minutos e tente novamente.';
+            } else if (err?.code === 'E_NETWORK') {
+                error = 'Não foi possível conectar ao servidor. Verifique sua conexão.';
             } else {
-                error = 'Credenciais inválidas. Use admin@nettown.com / admin';
-                isLoading = false;
+                error = err?.message ?? 'Não foi possível entrar. Tente novamente.';
             }
-        }, 1000);
+            isLoading = false;
+        }
     }
 </script>
 
@@ -138,7 +145,7 @@
         </div>
         
         <div class="mt-8 text-center text-xs text-slate-400 dark:text-slate-500 transition-colors">
-            <p>Dica para testar: use admin@nettown.com / admin</p>
+            <p>Acesso restrito a usuários autorizados.</p>
         </div>
     </div>
 </div>