Add super admin user management
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m46s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m46s
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
53
backend/routes/userRoutes.js
Normal file
53
backend/routes/userRoutes.js
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
208
backend/services/userService.js
Normal file
208
backend/services/userService.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user