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
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { ADMIN_EMAIL, ADMIN_PASSWORD, API_KEY, JWT_SECRET } = require('./config');
|
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 verifyToken = (req, res, next) => {
|
||||||
const authHeader = req.headers.authorization;
|
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 authenticateAPIKey = (req, res, next) => {
|
||||||
const apiKey = req.headers['x-api-key'];
|
const apiKey = req.headers['x-api-key'];
|
||||||
if (apiKey === API_KEY) {
|
if (apiKey === API_KEY) {
|
||||||
@@ -25,16 +37,46 @@ const authenticateAPIKey = (req, res, next) => {
|
|||||||
res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
|
res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const login = (email, password) => {
|
const buildTokenResponse = (user) => {
|
||||||
if (email !== ADMIN_EMAIL || password !== ADMIN_PASSWORD) {
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' });
|
return buildTokenResponse({
|
||||||
|
...publicUserFields(dbUser),
|
||||||
|
role: 'user'
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
verifyToken,
|
verifyToken,
|
||||||
|
verifySuperAdmin,
|
||||||
authenticateAPIKey,
|
authenticateAPIKey,
|
||||||
login
|
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(`
|
await pool.query(`
|
||||||
ALTER TABLE stock_campaign_queue
|
ALTER TABLE stock_campaign_queue
|
||||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
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_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_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_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.');
|
console.log('Database initialized successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -3,16 +3,21 @@ const { login } = require('../auth');
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post('/login', (req, res) => {
|
router.post('/login', async (req, res, next) => {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
const token = login(email, password);
|
|
||||||
|
|
||||||
if (!token) {
|
try {
|
||||||
|
const authResult = await login(email, password);
|
||||||
|
|
||||||
|
if (!authResult) {
|
||||||
res.status(401).json({ error: 'Invalid credentials' });
|
res.status(401).json({ error: 'Invalid credentials' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ token });
|
res.json(authResult);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
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 campaignRoutes = require('./routes/campaignRoutes');
|
||||||
const internalRoutes = require('./routes/internalRoutes');
|
const internalRoutes = require('./routes/internalRoutes');
|
||||||
const analyticsRoutes = require('./routes/analyticsRoutes');
|
const analyticsRoutes = require('./routes/analyticsRoutes');
|
||||||
|
const userRoutes = require('./routes/userRoutes');
|
||||||
|
|
||||||
const createApp = () => {
|
const createApp = () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -19,8 +20,25 @@ const createApp = () => {
|
|||||||
app.use('/api', stockRoutes);
|
app.use('/api', stockRoutes);
|
||||||
app.use('/api', campaignRoutes);
|
app.use('/api', campaignRoutes);
|
||||||
app.use('/api', analyticsRoutes);
|
app.use('/api', analyticsRoutes);
|
||||||
|
app.use('/api', userRoutes);
|
||||||
app.use('/api/internal', internalRoutes);
|
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;
|
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
|
||||||
|
};
|
||||||
11
src/App.tsx
11
src/App.tsx
@@ -2,7 +2,7 @@ import React, { Suspense } from 'react';
|
|||||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import { isAuthenticated } from './dataService';
|
import { isAuthenticated, isSuperAdmin } from './dataService';
|
||||||
|
|
||||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||||
const Products = React.lazy(() => import('./pages/Products'));
|
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 Campaigns = React.lazy(() => import('./pages/Campaigns'));
|
||||||
const Rfm = React.lazy(() => import('./pages/Rfm'));
|
const Rfm = React.lazy(() => import('./pages/Rfm'));
|
||||||
const Login = React.lazy(() => import('./pages/Login'));
|
const Login = React.lazy(() => import('./pages/Login'));
|
||||||
|
const AdminUsers = React.lazy(() => import('./pages/AdminUsers'));
|
||||||
|
|
||||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -21,6 +22,13 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SuperAdminRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
if (!isSuperAdmin()) {
|
||||||
|
return <Navigate to="/graph" replace />;
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
const RouteFallback = () => (
|
const RouteFallback = () => (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
|
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
|
||||||
<Loader2 className="h-8 w-8 animate-spin" />
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
@@ -41,6 +49,7 @@ function App() {
|
|||||||
<Route path="clients/:name" element={<ClientDetails />} />
|
<Route path="clients/:name" element={<ClientDetails />} />
|
||||||
<Route path="rfm" element={<Rfm />} />
|
<Route path="rfm" element={<Rfm />} />
|
||||||
<Route path="campaigns" element={<Campaigns />} />
|
<Route path="campaigns" element={<Campaigns />} />
|
||||||
|
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useState, useEffect } from 'react';
|
import { useCallback, useState, useEffect } from 'react';
|
||||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
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 type { DateRange, OrderData, StockData } from '../types';
|
||||||
import { fetchData, fetchStock, logout } from '../dataService';
|
import { fetchData, fetchStock, isSuperAdmin, logout } from '../dataService';
|
||||||
import { rangeForLastDays } from '../dateRanges';
|
import { rangeForLastDays } from '../dateRanges';
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
@@ -78,13 +78,20 @@ const Layout = () => {
|
|||||||
localStorage.setItem('graph_sidebar_collapsed', String(newState));
|
localStorage.setItem('graph_sidebar_collapsed', String(newState));
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigation = [
|
const appNavigation = [
|
||||||
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
|
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
|
||||||
{ name: 'Produtos', href: '/products', icon: Package },
|
{ name: 'Produtos', href: '/products', icon: Package },
|
||||||
{ name: 'Clientes', href: '/clients', icon: Users },
|
{ name: 'Clientes', href: '/clients', icon: Users },
|
||||||
{ name: 'RFV', href: '/rfm', icon: Grid3X3 },
|
{ name: 'RFV', href: '/rfm', icon: Grid3X3 },
|
||||||
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
{ 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 (
|
return (
|
||||||
<div className="flex h-screen bg-dark-bg text-dark-text overflow-hidden">
|
<div className="flex h-screen bg-dark-bg text-dark-text overflow-hidden">
|
||||||
@@ -112,24 +119,36 @@ const Layout = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
|
<nav className="flex-1 space-y-6 overflow-y-auto p-4">
|
||||||
{navigation.map((item) => {
|
{navigationSections.map((section) => (
|
||||||
|
<div key={section.label} className="space-y-2">
|
||||||
|
{!isSidebarCollapsed && (
|
||||||
|
<div className="px-3 text-xs font-bold uppercase tracking-widest text-dark-muted">
|
||||||
|
{section.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{section.items.map((item) => {
|
||||||
const isActive = location.pathname === item.href || (item.href !== '/graph' && location.pathname.startsWith(item.href));
|
const isActive = location.pathname === item.href || (item.href !== '/graph' && location.pathname.startsWith(item.href));
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={`flex items-center space-x-3 px-4 py-3 rounded-xl transition-all ${
|
className={`flex items-center rounded-xl px-4 py-3 transition-all ${
|
||||||
|
isSidebarCollapsed ? 'justify-center' : 'space-x-3'
|
||||||
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-brand-primary/10 text-brand-primary font-semibold shadow-md shadow-brand-primary/5'
|
? 'bg-brand-primary/10 text-brand-primary font-semibold shadow-md shadow-brand-primary/5'
|
||||||
: 'text-dark-muted hover:bg-dark-card hover:text-dark-text'
|
: 'text-dark-muted hover:bg-dark-card hover:text-dark-text'
|
||||||
}`}
|
}`}
|
||||||
|
title={isSidebarCollapsed ? item.name : undefined}
|
||||||
>
|
>
|
||||||
<item.icon className="w-5 h-5 shrink-0" />
|
<item.icon className="h-5 w-5 shrink-0" />
|
||||||
{!isSidebarCollapsed && <span className="font-medium">{item.name}</span>}
|
{!isSidebarCollapsed && <span className="font-medium">{item.name}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-dark-border">
|
<div className="p-4 border-t border-dark-border">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, DashboardAnalytics, DateRange, OrderData, RfmAnalytics, StockData } from './types';
|
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types';
|
||||||
import { formatDateParam } from './dateRanges';
|
import { formatDateParam } from './dateRanges';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
@@ -14,8 +14,10 @@ export const login = async (email: string, password: string): Promise<boolean> =
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json() as { token?: string; user?: AuthUser };
|
||||||
|
if (!data.token || !data.user) return false;
|
||||||
localStorage.setItem('auth_token', data.token);
|
localStorage.setItem('auth_token', data.token);
|
||||||
|
localStorage.setItem('auth_user', JSON.stringify(data.user));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -27,6 +29,7 @@ export const login = async (email: string, password: string): Promise<boolean> =
|
|||||||
|
|
||||||
export const logout = () => {
|
export const logout = () => {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('auth_user');
|
||||||
window.location.href = '/#/login';
|
window.location.href = '/#/login';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +37,22 @@ export const isAuthenticated = (): boolean => {
|
|||||||
return !!localStorage.getItem('auth_token');
|
return !!localStorage.getItem('auth_token');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCurrentUser = (): AuthUser | null => {
|
||||||
|
const rawUser = localStorage.getItem('auth_user');
|
||||||
|
if (!rawUser) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawUser) as AuthUser;
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('auth_user');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSuperAdmin = (): boolean => {
|
||||||
|
return getCurrentUser()?.role === 'super_admin';
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchStock = async (): Promise<StockData[]> => {
|
export const fetchStock = async (): Promise<StockData[]> => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = localStorage.getItem('auth_token');
|
||||||
@@ -171,6 +190,59 @@ export const retryCampaignGroup = async (baseProductName: string): Promise<boole
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchUsers = async (): Promise<ManagedUser[]> => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch('/users');
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const data = await response.json() as { users?: ManagedUser[] };
|
||||||
|
return data.users || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch users failed', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUser = async (payload: { name: string; email: string; password?: string }): Promise<CreateUserResult> => {
|
||||||
|
const response = await authFetch('/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || 'Não foi possível criar o usuário.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as CreateUserResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUser = async (id: number, payload: { name: string; email: string; isActive: boolean; password?: string }): Promise<ManagedUser> => {
|
||||||
|
const response = await authFetch(`/users/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || 'Não foi possível editar o usuário.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.user as ManagedUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUser = async (id: number): Promise<void> => {
|
||||||
|
const response = await authFetch(`/users/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
throw new Error(data?.error || 'Não foi possível excluir o usuário.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const parseOrderDate = (dateStr: string): Date => {
|
export const parseOrderDate = (dateStr: string): Date => {
|
||||||
if (!dateStr) return new Date(0);
|
if (!dateStr) return new Date(0);
|
||||||
if (dateStr.includes('T')) return new Date(dateStr);
|
if (dateStr.includes('T')) return new Date(dateStr);
|
||||||
|
|||||||
621
src/pages/AdminUsers.tsx
Normal file
621
src/pages/AdminUsers.tsx
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||||
|
import {
|
||||||
|
AtSign,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Edit3,
|
||||||
|
KeyRound,
|
||||||
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
User as UserIcon,
|
||||||
|
X
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { createUser, deleteUser as deleteManagedUser, fetchUsers, updateUser } from '../dataService';
|
||||||
|
import type { ManagedUser } from '../types';
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'active' | 'inactive';
|
||||||
|
type PasswordMode = 'auto' | 'manual';
|
||||||
|
type Notice = {
|
||||||
|
tone: 'success' | 'warning' | 'error';
|
||||||
|
text: string;
|
||||||
|
temporaryPassword?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions: Array<{ key: StatusFilter; label: string }> = [
|
||||||
|
{ key: 'all', label: 'Todos' },
|
||||||
|
{ key: 'active', label: 'Ativos' },
|
||||||
|
{ key: 'inactive', label: 'Inativos' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatDateTime = (value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!value || Number.isNaN(date.getTime())) return '-';
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (!parts.length) return '?';
|
||||||
|
return parts.slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdminUsers = () => {
|
||||||
|
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||||
|
const [userToDelete, setUserToDelete] = useState<ManagedUser | null>(null);
|
||||||
|
const [passwordMode, setPasswordMode] = useState<PasswordMode>('auto');
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [notice, setNotice] = useState<Notice | null>(null);
|
||||||
|
const [copyLabel, setCopyLabel] = useState('Copiar');
|
||||||
|
|
||||||
|
const activeCount = useMemo(() => users.filter((user) => user.isActive).length, [users]);
|
||||||
|
const inactiveCount = users.length - activeCount;
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
const query = searchTerm.trim().toLowerCase();
|
||||||
|
|
||||||
|
return users.filter((user) => {
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === 'all' ||
|
||||||
|
(statusFilter === 'active' && user.isActive) ||
|
||||||
|
(statusFilter === 'inactive' && !user.isActive);
|
||||||
|
const matchesQuery =
|
||||||
|
!query ||
|
||||||
|
user.name.toLowerCase().includes(query) ||
|
||||||
|
user.email.toLowerCase().includes(query);
|
||||||
|
|
||||||
|
return matchesStatus && matchesQuery;
|
||||||
|
});
|
||||||
|
}, [searchTerm, statusFilter, users]);
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
setUsers(await fetchUsers());
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// User management needs an initial API load when the admin page opens.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
void loadUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setName('');
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
|
setPasswordMode('auto');
|
||||||
|
setIsActive(true);
|
||||||
|
setEditingUser(null);
|
||||||
|
setNotice(null);
|
||||||
|
setCopyLabel('Copiar');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
setIsModalOpen(false);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
resetForm();
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (user: ManagedUser) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setName(user.name);
|
||||||
|
setEmail(user.email);
|
||||||
|
setIsActive(user.isActive);
|
||||||
|
setPassword('');
|
||||||
|
setPasswordMode('auto');
|
||||||
|
setNotice(null);
|
||||||
|
setCopyLabel('Copiar');
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateUser = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setNotice(null);
|
||||||
|
setCopyLabel('Copiar');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createUser({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password: passwordMode === 'manual' ? password : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
setUsers((currentUsers) => [result.user, ...currentUsers]);
|
||||||
|
|
||||||
|
if (result.temporaryPassword) {
|
||||||
|
setNotice({
|
||||||
|
tone: 'success',
|
||||||
|
text: 'Acesso criado. Copie a senha gerada antes de fechar.',
|
||||||
|
temporaryPassword: result.temporaryPassword
|
||||||
|
});
|
||||||
|
setName('');
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
|
setPasswordMode('auto');
|
||||||
|
} else {
|
||||||
|
setNotice({
|
||||||
|
tone: 'success',
|
||||||
|
text: 'Acesso criado com a senha definida.'
|
||||||
|
});
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
resetForm();
|
||||||
|
}, 900);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({
|
||||||
|
tone: 'error',
|
||||||
|
text: error instanceof Error ? error.message : 'Não foi possível criar o acesso.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateUser = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editingUser) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setNotice(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedUser = await updateUser(editingUser.id, {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
isActive,
|
||||||
|
password: passwordMode === 'manual' ? password : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
setUsers((currentUsers) => currentUsers.map((user) => user.id === updatedUser.id ? updatedUser : user));
|
||||||
|
setNotice({
|
||||||
|
tone: 'success',
|
||||||
|
text: 'Usuário atualizado.'
|
||||||
|
});
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
resetForm();
|
||||||
|
}, 700);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({
|
||||||
|
tone: 'error',
|
||||||
|
text: error instanceof Error ? error.message : 'Não foi possível editar o usuário.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async () => {
|
||||||
|
if (!userToDelete) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await deleteManagedUser(userToDelete.id);
|
||||||
|
setUsers((currentUsers) => currentUsers.filter((user) => user.id !== userToDelete.id));
|
||||||
|
setUserToDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({
|
||||||
|
tone: 'error',
|
||||||
|
text: error instanceof Error ? error.message : 'Não foi possível excluir o usuário.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyTemporaryPassword = async () => {
|
||||||
|
if (!notice?.temporaryPassword) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(notice.temporaryPassword);
|
||||||
|
setCopyLabel('Copiado');
|
||||||
|
window.setTimeout(() => setCopyLabel('Copiar'), 1600);
|
||||||
|
} catch {
|
||||||
|
setCopyLabel('Falhou');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const noticeClass = notice?.tone === 'error'
|
||||||
|
? 'border-red-500/30 bg-red-500/10 text-red-300'
|
||||||
|
: notice?.tone === 'warning'
|
||||||
|
? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
|
||||||
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<header className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-brand-primary">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Super admin
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-dark-text">Usuários</h1>
|
||||||
|
<p className="mt-1 text-sm text-dark-muted">Controle quem acessa o painel.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCreateModal}
|
||||||
|
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-zinc-950 transition-colors hover:bg-brand-primary/90"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Novo usuário
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-dark-border bg-dark-card">
|
||||||
|
<div className="flex flex-col gap-4 border-b border-dark-border p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="grid grid-cols-3 divide-x divide-dark-border overflow-hidden rounded-lg border border-dark-border bg-dark-input lg:w-[360px]">
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-xs font-semibold text-dark-muted">Total</p>
|
||||||
|
<p className="mt-1 text-xl font-bold text-dark-text">{users.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-xs font-semibold text-dark-muted">Ativos</p>
|
||||||
|
<p className="mt-1 text-xl font-bold text-emerald-300">{activeCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-xs font-semibold text-dark-muted">Inativos</p>
|
||||||
|
<p className="mt-1 text-xl font-bold text-red-300">{inactiveCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
|
<div className="flex h-10 min-w-0 items-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 md:w-80">
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-sm text-dark-text outline-none placeholder:text-dark-muted"
|
||||||
|
placeholder="Buscar nome ou e-mail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 rounded-lg border border-dark-border bg-dark-input p-1">
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(option.key)}
|
||||||
|
className={`h-8 cursor-pointer rounded-md px-3 text-sm font-semibold transition-colors ${
|
||||||
|
statusFilter === option.key
|
||||||
|
? 'bg-dark-card text-dark-text shadow-sm'
|
||||||
|
: 'text-dark-muted hover:text-dark-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-80 items-center justify-center text-brand-primary">
|
||||||
|
<Loader2 className="h-7 w-7 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<div className="flex h-80 flex-col items-center justify-center px-6 text-center">
|
||||||
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted">
|
||||||
|
<UserIcon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-base font-bold text-dark-text">Nenhum usuário cadastrado</p>
|
||||||
|
<p className="mt-1 max-w-sm text-sm text-dark-muted">Crie o primeiro acesso para liberar o painel a outra pessoa.</p>
|
||||||
|
</div>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<div className="flex h-80 flex-col items-center justify-center px-6 text-center">
|
||||||
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted">
|
||||||
|
<Search className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-base font-bold text-dark-text">Nenhum resultado</p>
|
||||||
|
<p className="mt-1 text-sm text-dark-muted">Ajuste a busca ou limpe os filtros.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[760px] text-left">
|
||||||
|
<thead className="border-b border-dark-border text-xs uppercase tracking-widest text-dark-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3 font-bold">Usuário</th>
|
||||||
|
<th className="px-5 py-3 font-bold">E-mail</th>
|
||||||
|
<th className="px-5 py-3 font-bold">Status</th>
|
||||||
|
<th className="px-5 py-3 text-right font-bold">Criado em</th>
|
||||||
|
<th className="px-5 py-3 text-right font-bold">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dark-border">
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<tr key={user.id} className="text-sm transition-colors hover:bg-dark-input/45">
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-primary/10 text-sm font-bold text-brand-primary">
|
||||||
|
{getInitials(user.name)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold text-dark-text">{user.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-dark-muted">
|
||||||
|
<Mail className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${
|
||||||
|
user.isActive ? 'bg-emerald-500/10 text-emerald-300' : 'bg-red-500/10 text-red-300'
|
||||||
|
}`}>
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${user.isActive ? 'bg-emerald-300' : 'bg-red-300'}`} />
|
||||||
|
{user.isActive ? 'Ativo' : 'Inativo'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-right text-dark-muted">{formatDateTime(user.createdAt)}</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditModal(user)}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary"
|
||||||
|
aria-label={`Editar ${user.name}`}
|
||||||
|
title="Editar"
|
||||||
|
>
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setNotice(null);
|
||||||
|
setUserToDelete(user);
|
||||||
|
}}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300"
|
||||||
|
aria-label={`Excluir ${user.name}`}
|
||||||
|
title="Excluir"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6">
|
||||||
|
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-dark-border bg-dark-card shadow-2xl">
|
||||||
|
<div className="flex items-start justify-between border-b border-dark-border px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-dark-text">{editingUser ? 'Editar usuário' : 'Novo usuário'}</h2>
|
||||||
|
<p className="mt-1 text-sm text-dark-muted">
|
||||||
|
{editingUser ? 'Atualize dados, status e senha de acesso.' : 'Crie um acesso individual para o painel.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-dark-muted transition-colors hover:bg-dark-input hover:text-dark-text"
|
||||||
|
aria-label="Fechar"
|
||||||
|
title="Fechar"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser} className="space-y-4 p-5">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">Nome</span>
|
||||||
|
<div className="flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary">
|
||||||
|
<UserIcon className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
||||||
|
placeholder="Nome completo"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">E-mail</span>
|
||||||
|
<div className="flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary">
|
||||||
|
<AtSign className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
||||||
|
placeholder="usuario@empresa.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">{editingUser ? 'Senha' : 'Senha inicial'}</span>
|
||||||
|
<div className="grid grid-cols-2 rounded-lg border border-dark-border bg-dark-input p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPasswordMode('auto')}
|
||||||
|
className={`h-9 cursor-pointer rounded-md text-sm font-semibold transition-colors ${passwordMode === 'auto' ? 'bg-dark-card text-dark-text' : 'text-dark-muted hover:text-dark-text'}`}
|
||||||
|
>
|
||||||
|
{editingUser ? 'Manter' : 'Gerar'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPasswordMode('manual')}
|
||||||
|
className={`h-9 cursor-pointer rounded-md text-sm font-semibold transition-colors ${passwordMode === 'manual' ? 'bg-dark-card text-dark-text' : 'text-dark-muted hover:text-dark-text'}`}
|
||||||
|
>
|
||||||
|
{editingUser ? 'Alterar' : 'Definir'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passwordMode === 'manual' && (
|
||||||
|
<div className="mt-3 flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary">
|
||||||
|
<KeyRound className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
||||||
|
placeholder="Mínimo de 6 caracteres"
|
||||||
|
minLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingUser && (
|
||||||
|
<label className="flex cursor-pointer items-center justify-between rounded-lg border border-dark-border bg-dark-input px-3 py-3">
|
||||||
|
<div>
|
||||||
|
<span className="block text-sm font-semibold text-dark-text">Usuário ativo</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-dark-muted">Usuários inativos não conseguem entrar.</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(event) => setIsActive(event.target.checked)}
|
||||||
|
className="h-5 w-5 cursor-pointer accent-brand-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notice && (
|
||||||
|
<div className={`rounded-lg border px-3 py-3 text-sm ${noticeClass}`}>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{notice.tone === 'error' ? <X className="mt-0.5 h-4 w-4 shrink-0" /> : <Check className="mt-0.5 h-4 w-4 shrink-0" />}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p>{notice.text}</p>
|
||||||
|
{notice.temporaryPassword && (
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<code className="min-w-0 flex-1 truncate rounded-md bg-black/25 px-3 py-2 text-amber-100">
|
||||||
|
{notice.temporaryPassword}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyTemporaryPassword}
|
||||||
|
className="inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-amber-400/30 px-3 text-xs font-bold text-amber-100 transition-colors hover:bg-amber-400/10"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
{copyLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-semibold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-zinc-950 transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||||
|
{editingUser ? 'Salvar alterações' : 'Criar usuário'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{userToDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6">
|
||||||
|
<div className="w-full max-w-md overflow-hidden rounded-lg border border-dark-border bg-dark-card shadow-2xl">
|
||||||
|
<div className="border-b border-dark-border px-5 py-4">
|
||||||
|
<h2 className="text-lg font-bold text-dark-text">Excluir usuário</h2>
|
||||||
|
<p className="mt-1 text-sm text-dark-muted">Esta ação remove o acesso imediatamente.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
|
<div className="rounded-lg border border-dark-border bg-dark-input p-4">
|
||||||
|
<p className="font-semibold text-dark-text">{userToDelete.name}</p>
|
||||||
|
<p className="mt-1 text-sm text-dark-muted">{userToDelete.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notice?.tone === 'error' && (
|
||||||
|
<div className={`rounded-lg border px-3 py-3 text-sm ${noticeClass}`}>
|
||||||
|
{notice.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
setUserToDelete(null);
|
||||||
|
setNotice(null);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-semibold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteUser}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-red-500 px-4 text-sm font-bold text-white transition-colors hover:bg-red-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||||
|
Excluir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminUsers;
|
||||||
23
src/types.ts
23
src/types.ts
@@ -24,6 +24,29 @@ export interface DateRange {
|
|||||||
end: Date;
|
end: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthRole = 'super_admin' | 'user';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number | null;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: AuthRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserResult {
|
||||||
|
user: ManagedUser;
|
||||||
|
temporaryPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardAnalytics {
|
export interface DashboardAnalytics {
|
||||||
totalRevenue: number;
|
totalRevenue: number;
|
||||||
totalOrders: number;
|
totalOrders: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user