Add super admin user management
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m46s

This commit is contained in:
Cauê Faleiros
2026-06-16 15:46:04 -03:00
parent f811d3c0ab
commit c8d475808b
12 changed files with 1126 additions and 34 deletions

View File

@@ -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
};

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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
};