diff --git a/.gitignore b/.gitignore index a547bf3..438657a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/backend/auth.js b/backend/auth.js index 7e0a59e..d2bb446 100644 --- a/backend/auth.js +++ b/backend/auth.js @@ -1,5 +1,6 @@ const jwt = require('jsonwebtoken'); const { ADMIN_EMAIL, ADMIN_PASSWORD, API_KEY, JWT_SECRET } = require('./config'); +const { findUserByEmail, normalizeEmail, publicUserFields, verifyPassword } = require('./services/userService'); const verifyToken = (req, res, next) => { const authHeader = req.headers.authorization; @@ -15,6 +16,17 @@ const verifyToken = (req, res, next) => { }); }; +const verifySuperAdmin = (req, res, next) => { + verifyToken(req, res, () => { + if (req.user?.role !== 'super_admin') { + res.status(403).json({ error: 'Super admin access required' }); + return; + } + + next(); + }); +}; + const authenticateAPIKey = (req, res, next) => { const apiKey = req.headers['x-api-key']; if (apiKey === API_KEY) { @@ -25,16 +37,46 @@ const authenticateAPIKey = (req, res, next) => { res.status(401).json({ error: 'Unauthorized: Invalid API Key' }); }; -const login = (email, password) => { - if (email !== ADMIN_EMAIL || password !== ADMIN_PASSWORD) { +const buildTokenResponse = (user) => { + const token = jwt.sign( + { + email: user.email, + role: user.role, + userId: user.id || null + }, + JWT_SECRET, + { expiresIn: '24h' } + ); + + return { token, user }; +}; + +const login = async (email, password) => { + const normalizedEmail = normalizeEmail(email); + + if (normalizedEmail === normalizeEmail(ADMIN_EMAIL) && password === ADMIN_PASSWORD) { + return buildTokenResponse({ + id: null, + name: 'Super Admin', + email: normalizedEmail, + role: 'super_admin' + }); + } + + const dbUser = await findUserByEmail(normalizedEmail); + if (!dbUser || !dbUser.is_active || !verifyPassword(password, dbUser.password_hash)) { return null; } - return jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' }); + return buildTokenResponse({ + ...publicUserFields(dbUser), + role: 'user' + }); }; module.exports = { verifyToken, + verifySuperAdmin, authenticateAPIKey, login }; diff --git a/backend/db.js b/backend/db.js index 1b59730..3d7d62b 100644 --- a/backend/db.js +++ b/backend/db.js @@ -85,6 +85,26 @@ const initDB = async () => { ); `); + await pool.query(` + CREATE TABLE IF NOT EXISTS app_users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + `); + + await pool.query(` + ALTER TABLE app_users + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', + ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP, + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo', + ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; + `).catch(() => {}); + await pool.query(` ALTER TABLE stock_campaign_queue ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', @@ -122,6 +142,7 @@ const initDB = async () => { await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_produto_id ON orders (produto_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_data_pedido_date ON orders (data_pedido_date);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_app_users_email ON app_users (LOWER(email));`); console.log('Database initialized successfully.'); } catch (err) { diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index 3076d92..723a25b 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -3,16 +3,21 @@ const { login } = require('../auth'); const router = express.Router(); -router.post('/login', (req, res) => { +router.post('/login', async (req, res, next) => { const { email, password } = req.body; - const token = login(email, password); - if (!token) { - res.status(401).json({ error: 'Invalid credentials' }); - return; + try { + const authResult = await login(email, password); + + if (!authResult) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + res.json(authResult); + } catch (error) { + next(error); } - - res.json({ token }); }); module.exports = router; diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 0000000..8e66c6c --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,53 @@ +const express = require('express'); +const { verifySuperAdmin } = require('../auth'); +const { createUser, deleteUser, listUsers, updateUser } = require('../services/userService'); + +const router = express.Router(); + +router.get('/users', verifySuperAdmin, async (req, res, next) => { + try { + const users = await listUsers(); + res.json({ users }); + } catch (error) { + next(error); + } +}); + +router.post('/users', verifySuperAdmin, async (req, res, next) => { + try { + const { name, email, password } = req.body || {}; + const { user, password: userPassword, generatedPassword } = await createUser({ name, email, password }); + + const responsePayload = { + user + }; + + if (generatedPassword) { + responsePayload.temporaryPassword = userPassword; + } + + res.status(201).json(responsePayload); + } catch (error) { + next(error); + } +}); + +router.patch('/users/:id', verifySuperAdmin, async (req, res, next) => { + try { + const user = await updateUser(req.params.id, req.body || {}); + res.json({ user }); + } catch (error) { + next(error); + } +}); + +router.delete('/users/:id', verifySuperAdmin, async (req, res, next) => { + try { + await deleteUser(req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index a4092a1..87febfc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7,6 +7,7 @@ const stockRoutes = require('./routes/stockRoutes'); const campaignRoutes = require('./routes/campaignRoutes'); const internalRoutes = require('./routes/internalRoutes'); const analyticsRoutes = require('./routes/analyticsRoutes'); +const userRoutes = require('./routes/userRoutes'); const createApp = () => { const app = express(); @@ -19,8 +20,25 @@ const createApp = () => { app.use('/api', stockRoutes); app.use('/api', campaignRoutes); app.use('/api', analyticsRoutes); + app.use('/api', userRoutes); app.use('/api/internal', internalRoutes); + app.use((err, req, res, next) => { + if (res.headersSent) { + next(err); + return; + } + + const statusCode = err.statusCode || 500; + if (statusCode >= 500) { + console.error(err); + } + + res.status(statusCode).json({ + error: err.message || 'Internal server error' + }); + }); + return app; }; diff --git a/backend/services/userService.js b/backend/services/userService.js new file mode 100644 index 0000000..5ba7def --- /dev/null +++ b/backend/services/userService.js @@ -0,0 +1,208 @@ +const crypto = require('crypto'); +const { pool } = require('../db'); + +const HASH_ALGORITHM = 'scrypt'; +const KEY_LENGTH = 64; + +const normalizeEmail = (email) => String(email || '').trim().toLowerCase(); + +const generatePassword = () => { + return crypto.randomBytes(9).toString('base64url'); +}; + +const hashPassword = (password) => { + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.scryptSync(password, salt, KEY_LENGTH).toString('hex'); + return `${HASH_ALGORITHM}:${salt}:${hash}`; +}; + +const verifyPassword = (password, passwordHash) => { + const [algorithm, salt, storedHash] = String(passwordHash || '').split(':'); + if (algorithm !== HASH_ALGORITHM || !salt || !storedHash) return false; + + const hash = crypto.scryptSync(password, salt, KEY_LENGTH); + const storedBuffer = Buffer.from(storedHash, 'hex'); + if (storedBuffer.length !== hash.length) return false; + + return crypto.timingSafeEqual(hash, storedBuffer); +}; + +const publicUserFields = (row) => ({ + id: row.id, + name: row.name, + email: row.email, + isActive: row.is_active, + createdAt: row.created_at, + updatedAt: row.updated_at +}); + +const findUserByEmail = async (email) => { + const normalizedEmail = normalizeEmail(email); + const result = await pool.query( + `SELECT id, name, email, password_hash, is_active, created_at, updated_at + FROM app_users + WHERE LOWER(email) = $1 + LIMIT 1`, + [normalizedEmail] + ); + + return result.rows[0] || null; +}; + +const listUsers = async () => { + const result = await pool.query( + `SELECT id, name, email, is_active, created_at, updated_at + FROM app_users + ORDER BY created_at DESC, id DESC` + ); + + return result.rows.map(publicUserFields); +}; + +const createUser = async ({ name, email, password }) => { + const normalizedName = String(name || '').trim(); + const normalizedEmail = normalizeEmail(email); + const generatedPassword = !password; + const plainPassword = String(password || generatePassword()).trim(); + + if (!normalizedName) { + const error = new Error('Name is required'); + error.statusCode = 400; + throw error; + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) { + const error = new Error('Valid email is required'); + error.statusCode = 400; + throw error; + } + + if (plainPassword.length < 6) { + const error = new Error('Password must have at least 6 characters'); + error.statusCode = 400; + throw error; + } + + const passwordHash = hashPassword(plainPassword); + + try { + const result = await pool.query( + `INSERT INTO app_users (name, email, password_hash) + VALUES ($1, $2, $3) + RETURNING id, name, email, is_active, created_at, updated_at`, + [normalizedName, normalizedEmail, passwordHash] + ); + + return { + user: publicUserFields(result.rows[0]), + password: plainPassword, + generatedPassword + }; + } catch (error) { + if (error.code === '23505') { + const duplicateError = new Error('A user with this email already exists'); + duplicateError.statusCode = 409; + throw duplicateError; + } + + throw error; + } +}; + +const findUserById = async (id) => { + const result = await pool.query( + `SELECT id, name, email, password_hash, is_active, created_at, updated_at + FROM app_users + WHERE id = $1 + LIMIT 1`, + [id] + ); + + return result.rows[0] || null; +}; + +const updateUser = async (id, { name, email, password, isActive }) => { + const existingUser = await findUserById(id); + if (!existingUser) { + const error = new Error('User not found'); + error.statusCode = 404; + throw error; + } + + const normalizedName = String(name ?? existingUser.name).trim(); + const normalizedEmail = normalizeEmail(email ?? existingUser.email); + const normalizedIsActive = typeof isActive === 'boolean' ? isActive : existingUser.is_active; + const normalizedPassword = typeof password === 'string' ? password.trim() : ''; + + if (!normalizedName) { + const error = new Error('Name is required'); + error.statusCode = 400; + throw error; + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) { + const error = new Error('Valid email is required'); + error.statusCode = 400; + throw error; + } + + if (normalizedPassword && normalizedPassword.length < 6) { + const error = new Error('Password must have at least 6 characters'); + error.statusCode = 400; + throw error; + } + + const passwordHash = normalizedPassword ? hashPassword(normalizedPassword) : existingUser.password_hash; + + try { + const result = await pool.query( + `UPDATE app_users + SET name = $1, + email = $2, + password_hash = $3, + is_active = $4, + updated_at = CURRENT_TIMESTAMP + WHERE id = $5 + RETURNING id, name, email, is_active, created_at, updated_at`, + [normalizedName, normalizedEmail, passwordHash, normalizedIsActive, id] + ); + + return publicUserFields(result.rows[0]); + } catch (error) { + if (error.code === '23505') { + const duplicateError = new Error('A user with this email already exists'); + duplicateError.statusCode = 409; + throw duplicateError; + } + + throw error; + } +}; + +const deleteUser = async (id) => { + const result = await pool.query( + `DELETE FROM app_users + WHERE id = $1 + RETURNING id`, + [id] + ); + + if (!result.rowCount) { + const error = new Error('User not found'); + error.statusCode = 404; + throw error; + } +}; + +module.exports = { + createUser, + deleteUser, + findUserById, + findUserByEmail, + generatePassword, + listUsers, + normalizeEmail, + publicUserFields, + updateUser, + verifyPassword +}; diff --git a/src/App.tsx b/src/App.tsx index 48ed4f1..1f355bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import React, { Suspense } from 'react'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Loader2 } from 'lucide-react'; import Layout from './components/Layout'; -import { isAuthenticated } from './dataService'; +import { isAuthenticated, isSuperAdmin } from './dataService'; const Dashboard = React.lazy(() => import('./pages/Dashboard')); const Products = React.lazy(() => import('./pages/Products')); @@ -12,6 +12,7 @@ const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); const Campaigns = React.lazy(() => import('./pages/Campaigns')); const Rfm = React.lazy(() => import('./pages/Rfm')); const Login = React.lazy(() => import('./pages/Login')); +const AdminUsers = React.lazy(() => import('./pages/AdminUsers')); function PrivateRoute({ children }: { children: React.ReactNode }) { const location = useLocation(); @@ -21,6 +22,13 @@ function PrivateRoute({ children }: { children: React.ReactNode }) { return children; } +function SuperAdminRoute({ children }: { children: React.ReactNode }) { + if (!isSuperAdmin()) { + return ; + } + return children; +} + const RouteFallback = () => (
@@ -41,6 +49,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 7fc5ed8..d2c4e39 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,8 +1,8 @@ import { useCallback, useState, useEffect } from 'react'; import { Outlet, Link, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone, Grid3X3 } from 'lucide-react'; +import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone, Grid3X3, Shield } from 'lucide-react'; import type { DateRange, OrderData, StockData } from '../types'; -import { fetchData, fetchStock, logout } from '../dataService'; +import { fetchData, fetchStock, isSuperAdmin, logout } from '../dataService'; import { rangeForLastDays } from '../dateRanges'; const Layout = () => { @@ -78,13 +78,20 @@ const Layout = () => { localStorage.setItem('graph_sidebar_collapsed', String(newState)); }; - const navigation = [ + const appNavigation = [ { name: 'Dashboard', href: '/graph', icon: LayoutDashboard }, { name: 'Produtos', href: '/products', icon: Package }, { name: 'Clientes', href: '/clients', icon: Users }, { name: 'RFV', href: '/rfm', icon: Grid3X3 }, { name: 'Campanhas', href: '/campaigns', icon: Megaphone }, ]; + const adminNavigation = isSuperAdmin() + ? [{ name: 'Usuários', href: '/admin/users', icon: Shield }] + : []; + const navigationSections = [ + { label: 'Painel', items: appNavigation }, + ...(adminNavigation.length ? [{ label: 'Super admin', items: adminNavigation }] : []), + ]; return (
@@ -112,24 +119,36 @@ const Layout = () => {
)} -