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