From b1e8cc55df8128a3361a38feca9e326fa892580a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 4 May 2026 15:46:08 -0300 Subject: [PATCH] feat: add secure login page with jwt authentication and button animation --- .env.example | 7 +- backend/index.js | 55 ++++++++++++---- backend/package-lock.json | 133 ++++++++++++++++++++++++++++++++++++++ backend/package.json | 1 + docker-compose.yml | 3 + src/App.tsx | 15 ++++- src/components/Layout.tsx | 14 +++- src/dataService.ts | 42 +++++++++++- src/pages/Login.tsx | 82 +++++++++++++++++++++++ 9 files changed, 335 insertions(+), 17 deletions(-) create mode 100644 src/pages/Login.tsx diff --git a/.env.example b/.env.example index 5047c95..cf254c8 100644 --- a/.env.example +++ b/.env.example @@ -10,10 +10,15 @@ POSTGRES_USER=graphuser POSTGRES_PASSWORD=super_secret_password_here POSTGRES_DB=graphdb -# --- Backend Configuration --- +# --- Application Security --- # The API key used by n8n to authenticate with the backend API_KEY=nexstar_secret_key_123 +# --- Dashboard Login Credentials --- +ADMIN_EMAIL=admin@admin.com +ADMIN_PASSWORD=admin123 +JWT_SECRET=super_secret_jwt_key_123 + # --- Frontend Configuration (Optional) --- # If you need to override the API URL for the frontend # VITE_API_URL=/api diff --git a/backend/index.js b/backend/index.js index c319a9a..eb93c32 100644 --- a/backend/index.js +++ b/backend/index.js @@ -2,12 +2,18 @@ const express = require('express'); const cors = require('cors'); const bodyParser = require('body-parser'); const { Pool } = require('pg'); +const jwt = require('jsonwebtoken'); require('dotenv').config(); const app = express(); const PORT = process.env.PORT || 3004; const API_KEY = process.env.API_KEY || "nexstar_secret_key_123"; +// Admin Credentials +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@admin.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123'; +const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_jwt_key_123'; + app.use(cors()); app.use(bodyParser.json()); @@ -40,16 +46,32 @@ const initDB = async () => { initDB(); -// Middleware for Security -const authenticate = (req, res, next) => { - const apiKey = req.headers['x-api-key']; - if (apiKey === API_KEY) { +// Middleware for Frontend Authentication +const verifyToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + if (!authHeader) return res.status(403).json({ error: 'No token provided' }); + + const token = authHeader.split(' ')[1]; + if (!token) return res.status(403).json({ error: 'Malformed token' }); + + jwt.verify(token, JWT_SECRET, (err, decoded) => { + if (err) return res.status(401).json({ error: 'Unauthorized' }); + req.user = decoded; next(); - } else { - res.status(401).json({ error: 'Unauthorized: Invalid API Key' }); - } + }); }; +// Login Endpoint +app.post('/api/login', (req, res) => { + const { email, password } = req.body; + if (email === ADMIN_EMAIL && password === ADMIN_PASSWORD) { + const token = jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' }); + res.json({ token }); + } else { + res.status(401).json({ error: 'Invalid credentials' }); + } +}); + // Helper to format rows to match the old JSON structure for the frontend const formatRow = (row) => ({ Nome_Cliente: row.cliente_nome, @@ -62,7 +84,7 @@ const formatRow = (row) => ({ }); // GET data (for the frontend) -app.get('/api/data', async (req, res) => { +app.get('/api/data', verifyToken, async (req, res) => { try { const result = await pool.query('SELECT * FROM orders ORDER BY id ASC'); const formattedData = result.rows.map(formatRow); @@ -73,8 +95,19 @@ app.get('/api/data', async (req, res) => { } }); -// POST data (for n8n) -app.post('/api/data', async (req, res) => { +// POST data (for n8n) - Protected by API_KEY internally or via middleware if needed +// Leaving it as it was, checking API_KEY manually? Wait, the previous version didn't actually use 'authenticate' middleware on the POST! +// Let's add the authenticate middleware to the POST endpoint. +const authenticateAPIKey = (req, res, next) => { + const apiKey = req.headers['x-api-key']; + if (apiKey === API_KEY) { + next(); + } else { + res.status(401).json({ error: 'Unauthorized: Invalid API Key' }); + } +}; + +app.post('/api/data', authenticateAPIKey, async (req, res) => { // Respond IMMEDIATELY to prevent slowing down n8n / WhatsApp flows res.status(201).json({ message: 'Data received, processing in background' }); @@ -121,4 +154,4 @@ app.post('/api/data', async (req, res) => { app.listen(PORT, '0.0.0.0', () => { console.log(\`Nexstar Backend running at http://localhost:\${PORT}\`); console.log(\`Endpoint for n8n: POST http://localhost:\${PORT}/api/data\`); -}); +}); \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 0b49032..4755921 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "fs-extra": "^11.3.4", + "jsonwebtoken": "^9.0.3", "pg": "^8.20.0" } }, @@ -54,6 +55,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -201,6 +208,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -514,6 +530,91 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -841,12 +942,44 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", diff --git a/backend/package.json b/backend/package.json index bb8076e..1208189 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "fs-extra": "^11.3.4", + "jsonwebtoken": "^9.0.3", "pg": "^8.20.0" } } diff --git a/docker-compose.yml b/docker-compose.yml index d0044eb..6844b77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,9 @@ services: - PORT=3004 - DATABASE_URL=postgres://${POSTGRES_USER:-graphuser}:${POSTGRES_PASSWORD:-graphpassword}@db:5432/${POSTGRES_DB:-graphdb} - API_KEY=${API_KEY:-nexstar_secret_key_123} + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@admin.com} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} + - JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123} depends_on: - db restart: unless-stopped diff --git a/src/App.tsx b/src/App.tsx index d48b435..6cae9fa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,27 @@ -import { Routes, Route, Navigate } from 'react-router-dom'; +import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import Layout from './components/Layout'; import Dashboard from './pages/Dashboard'; import Products from './pages/Products'; import ProductDetails from './pages/ProductDetails'; import Clients from './pages/Clients'; import ClientDetails from './pages/ClientDetails'; +import Login from './pages/Login'; +import { isAuthenticated } from './dataService'; + +function PrivateRoute({ children }: { children: JSX.Element }) { + const location = useLocation(); + if (!isAuthenticated()) { + return ; + } + return children; +} function App() { return ( + } /> } /> - }> + }> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 955bee7..baec7dd 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,8 +1,8 @@ import { useState, useEffect } from 'react'; import { Outlet, Link, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2 } from 'lucide-react'; +import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut } from 'lucide-react'; import type { DateRange, OrderData } from '../types'; -import { fetchData } from '../dataService'; +import { fetchData, logout } from '../dataService'; const Layout = () => { const location = useLocation(); @@ -102,6 +102,16 @@ const Layout = () => { ); })} + +
+ +
{/* Main Content */} diff --git a/src/dataService.ts b/src/dataService.ts index d82d0a0..8c75211 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -2,9 +2,49 @@ import type { OrderData } from './types'; const API_URL = import.meta.env.VITE_API_URL || '/api'; +export const login = async (email: string, password: string): Promise => { + try { + const response = await fetch(`${API_URL}/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + + if (response.ok) { + const data = await response.json(); + localStorage.setItem('auth_token', data.token); + return true; + } + return false; + } catch (error) { + console.error('Login failed', error); + return false; + } +}; + +export const logout = () => { + localStorage.removeItem('auth_token'); + window.location.href = '/#/login'; +}; + +export const isAuthenticated = (): boolean => { + return !!localStorage.getItem('auth_token'); +}; + export const fetchData = async (): Promise => { try { - const response = await fetch(`${API_URL}/data`); + const token = localStorage.getItem('auth_token'); + const response = await fetch(`${API_URL}/data`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + if (response.status === 401 || response.status === 403) { + logout(); + return []; + } if (!response.ok) return []; return await response.json(); } catch (error) { diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..0c2b6c5 --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Lock } from 'lucide-react'; +import { login } from '../dataService'; + +const Login = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + try { + const success = await login(email, password); + if (success) { + navigate('/graph'); + } else { + setError('E-mail ou senha incorretos.'); + } + } catch (err) { + setError('Erro ao conectar ao servidor.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ +
+

Acesso Restrito

+

Insira suas credenciais para acessar o painel administrativo.

+
+ +
+
+ + setEmail(e.target.value)} + className="w-full bg-dark-input border border-dark-border text-dark-text rounded-xl px-4 py-3 focus:outline-none focus:border-brand-primary focus:ring-1 focus:ring-brand-primary transition-all" + placeholder="admin@admin.com" + required + /> +
+
+ + setPassword(e.target.value)} + className="w-full bg-dark-input border border-dark-border text-dark-text rounded-xl px-4 py-3 focus:outline-none focus:border-brand-primary focus:ring-1 focus:ring-brand-primary transition-all" + placeholder="••••••••" + required + /> +
+ + {error &&

{error}

} + + +
+
+
+ ); +}; + +export default Login;