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

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*

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

View File

@@ -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 <Navigate to="/graph" replace />;
}
return children;
}
const RouteFallback = () => (
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
<Loader2 className="h-8 w-8 animate-spin" />
@@ -41,6 +49,7 @@ function App() {
<Route path="clients/:name" element={<ClientDetails />} />
<Route path="rfm" element={<Rfm />} />
<Route path="campaigns" element={<Campaigns />} />
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
</Route>
</Routes>
</Suspense>

View File

@@ -1,8 +1,8 @@
import { useCallback, useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone, Grid3X3 } from 'lucide-react';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone, Grid3X3, Shield } from 'lucide-react';
import type { DateRange, OrderData, StockData } from '../types';
import { fetchData, fetchStock, logout } from '../dataService';
import { fetchData, fetchStock, isSuperAdmin, logout } from '../dataService';
import { rangeForLastDays } from '../dateRanges';
const Layout = () => {
@@ -78,13 +78,20 @@ const Layout = () => {
localStorage.setItem('graph_sidebar_collapsed', String(newState));
};
const navigation = [
const appNavigation = [
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
{ name: 'Produtos', href: '/products', icon: Package },
{ name: 'Clientes', href: '/clients', icon: Users },
{ name: 'RFV', href: '/rfm', icon: Grid3X3 },
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
];
const adminNavigation = isSuperAdmin()
? [{ name: 'Usuários', href: '/admin/users', icon: Shield }]
: [];
const navigationSections = [
{ label: 'Painel', items: appNavigation },
...(adminNavigation.length ? [{ label: 'Super admin', items: adminNavigation }] : []),
];
return (
<div className="flex h-screen bg-dark-bg text-dark-text overflow-hidden">
@@ -112,24 +119,36 @@ const Layout = () => {
</div>
)}
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
{navigation.map((item) => {
const isActive = location.pathname === item.href || (item.href !== '/graph' && location.pathname.startsWith(item.href));
return (
<Link
key={item.name}
to={item.href}
className={`flex items-center space-x-3 px-4 py-3 rounded-xl transition-all ${
isActive
? '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'
}`}
>
<item.icon className="w-5 h-5 shrink-0" />
{!isSidebarCollapsed && <span className="font-medium">{item.name}</span>}
</Link>
);
})}
<nav className="flex-1 space-y-6 overflow-y-auto p-4">
{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));
return (
<Link
key={item.name}
to={item.href}
className={`flex items-center rounded-xl px-4 py-3 transition-all ${
isSidebarCollapsed ? 'justify-center' : 'space-x-3'
} ${
isActive
? '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'
}`}
title={isSidebarCollapsed ? item.name : undefined}
>
<item.icon className="h-5 w-5 shrink-0" />
{!isSidebarCollapsed && <span className="font-medium">{item.name}</span>}
</Link>
);
})}
</div>
))}
</nav>
<div className="p-4 border-t border-dark-border">

View File

@@ -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';
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) {
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_user', JSON.stringify(data.user));
return true;
}
return false;
@@ -27,6 +29,7 @@ export const login = async (email: string, password: string): Promise<boolean> =
export const logout = () => {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
window.location.href = '/#/login';
};
@@ -34,6 +37,22 @@ export const isAuthenticated = (): boolean => {
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[]> => {
try {
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 => {
if (!dateStr) return new Date(0);
if (dateStr.includes('T')) return new Date(dateStr);

621
src/pages/AdminUsers.tsx Normal file
View 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;

View File

@@ -24,6 +24,29 @@ export interface DateRange {
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 {
totalRevenue: number;
totalOrders: number;