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-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { ADMIN_EMAIL, ADMIN_PASSWORD, API_KEY, JWT_SECRET } = require('./config');
|
||||
const { findUserByEmail, normalizeEmail, publicUserFields, verifyPassword } = require('./services/userService');
|
||||
|
||||
const verifyToken = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
@@ -15,6 +16,17 @@ const verifyToken = (req, res, next) => {
|
||||
});
|
||||
};
|
||||
|
||||
const verifySuperAdmin = (req, res, next) => {
|
||||
verifyToken(req, res, () => {
|
||||
if (req.user?.role !== 'super_admin') {
|
||||
res.status(403).json({ error: 'Super admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
const authenticateAPIKey = (req, res, next) => {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (apiKey === API_KEY) {
|
||||
@@ -25,16 +37,46 @@ const authenticateAPIKey = (req, res, next) => {
|
||||
res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
|
||||
};
|
||||
|
||||
const login = (email, password) => {
|
||||
if (email !== ADMIN_EMAIL || password !== ADMIN_PASSWORD) {
|
||||
const buildTokenResponse = (user) => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
userId: user.id || null
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
return { token, user };
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
|
||||
if (normalizedEmail === normalizeEmail(ADMIN_EMAIL) && password === ADMIN_PASSWORD) {
|
||||
return buildTokenResponse({
|
||||
id: null,
|
||||
name: 'Super Admin',
|
||||
email: normalizedEmail,
|
||||
role: 'super_admin'
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await findUserByEmail(normalizedEmail);
|
||||
if (!dbUser || !dbUser.is_active || !verifyPassword(password, dbUser.password_hash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' });
|
||||
return buildTokenResponse({
|
||||
...publicUserFields(dbUser),
|
||||
role: 'user'
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
verifyToken,
|
||||
verifySuperAdmin,
|
||||
authenticateAPIKey,
|
||||
login
|
||||
};
|
||||
|
||||
@@ -85,6 +85,26 @@ const initDB = async () => {
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE app_users
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE stock_campaign_queue
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
@@ -122,6 +142,7 @@ const initDB = async () => {
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_produto_id ON orders (produto_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_data_pedido_date ON orders (data_pedido_date);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_app_users_email ON app_users (LOWER(email));`);
|
||||
|
||||
console.log('Database initialized successfully.');
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,16 +3,21 @@ const { login } = require('../auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
router.post('/login', async (req, res, next) => {
|
||||
const { email, password } = req.body;
|
||||
const token = login(email, password);
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
try {
|
||||
const authResult = await login(email, password);
|
||||
|
||||
if (!authResult) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(authResult);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
53
backend/routes/userRoutes.js
Normal file
53
backend/routes/userRoutes.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const express = require('express');
|
||||
const { verifySuperAdmin } = require('../auth');
|
||||
const { createUser, deleteUser, listUsers, updateUser } = require('../services/userService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/users', verifySuperAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const users = await listUsers();
|
||||
res.json({ users });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/users', verifySuperAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { name, email, password } = req.body || {};
|
||||
const { user, password: userPassword, generatedPassword } = await createUser({ name, email, password });
|
||||
|
||||
const responsePayload = {
|
||||
user
|
||||
};
|
||||
|
||||
if (generatedPassword) {
|
||||
responsePayload.temporaryPassword = userPassword;
|
||||
}
|
||||
|
||||
res.status(201).json(responsePayload);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/users/:id', verifySuperAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const user = await updateUser(req.params.id, req.body || {});
|
||||
res.json({ user });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/users/:id', verifySuperAdmin, async (req, res, next) => {
|
||||
try {
|
||||
await deleteUser(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -7,6 +7,7 @@ const stockRoutes = require('./routes/stockRoutes');
|
||||
const campaignRoutes = require('./routes/campaignRoutes');
|
||||
const internalRoutes = require('./routes/internalRoutes');
|
||||
const analyticsRoutes = require('./routes/analyticsRoutes');
|
||||
const userRoutes = require('./routes/userRoutes');
|
||||
|
||||
const createApp = () => {
|
||||
const app = express();
|
||||
@@ -19,8 +20,25 @@ const createApp = () => {
|
||||
app.use('/api', stockRoutes);
|
||||
app.use('/api', campaignRoutes);
|
||||
app.use('/api', analyticsRoutes);
|
||||
app.use('/api', userRoutes);
|
||||
app.use('/api/internal', internalRoutes);
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
if (res.headersSent) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusCode = err.statusCode || 500;
|
||||
if (statusCode >= 500) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: err.message || 'Internal server error'
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
|
||||
208
backend/services/userService.js
Normal file
208
backend/services/userService.js
Normal file
@@ -0,0 +1,208 @@
|
||||
const crypto = require('crypto');
|
||||
const { pool } = require('../db');
|
||||
|
||||
const HASH_ALGORITHM = 'scrypt';
|
||||
const KEY_LENGTH = 64;
|
||||
|
||||
const normalizeEmail = (email) => String(email || '').trim().toLowerCase();
|
||||
|
||||
const generatePassword = () => {
|
||||
return crypto.randomBytes(9).toString('base64url');
|
||||
};
|
||||
|
||||
const hashPassword = (password) => {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.scryptSync(password, salt, KEY_LENGTH).toString('hex');
|
||||
return `${HASH_ALGORITHM}:${salt}:${hash}`;
|
||||
};
|
||||
|
||||
const verifyPassword = (password, passwordHash) => {
|
||||
const [algorithm, salt, storedHash] = String(passwordHash || '').split(':');
|
||||
if (algorithm !== HASH_ALGORITHM || !salt || !storedHash) return false;
|
||||
|
||||
const hash = crypto.scryptSync(password, salt, KEY_LENGTH);
|
||||
const storedBuffer = Buffer.from(storedHash, 'hex');
|
||||
if (storedBuffer.length !== hash.length) return false;
|
||||
|
||||
return crypto.timingSafeEqual(hash, storedBuffer);
|
||||
};
|
||||
|
||||
const publicUserFields = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
isActive: row.is_active,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
const findUserByEmail = async (email) => {
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, email, password_hash, is_active, created_at, updated_at
|
||||
FROM app_users
|
||||
WHERE LOWER(email) = $1
|
||||
LIMIT 1`,
|
||||
[normalizedEmail]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
const listUsers = async () => {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, email, is_active, created_at, updated_at
|
||||
FROM app_users
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
);
|
||||
|
||||
return result.rows.map(publicUserFields);
|
||||
};
|
||||
|
||||
const createUser = async ({ name, email, password }) => {
|
||||
const normalizedName = String(name || '').trim();
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
const generatedPassword = !password;
|
||||
const plainPassword = String(password || generatePassword()).trim();
|
||||
|
||||
if (!normalizedName) {
|
||||
const error = new Error('Name is required');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
|
||||
const error = new Error('Valid email is required');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (plainPassword.length < 6) {
|
||||
const error = new Error('Password must have at least 6 characters');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const passwordHash = hashPassword(plainPassword);
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO app_users (name, email, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, email, is_active, created_at, updated_at`,
|
||||
[normalizedName, normalizedEmail, passwordHash]
|
||||
);
|
||||
|
||||
return {
|
||||
user: publicUserFields(result.rows[0]),
|
||||
password: plainPassword,
|
||||
generatedPassword
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code === '23505') {
|
||||
const duplicateError = new Error('A user with this email already exists');
|
||||
duplicateError.statusCode = 409;
|
||||
throw duplicateError;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const findUserById = async (id) => {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, email, password_hash, is_active, created_at, updated_at
|
||||
FROM app_users
|
||||
WHERE id = $1
|
||||
LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
const updateUser = async (id, { name, email, password, isActive }) => {
|
||||
const existingUser = await findUserById(id);
|
||||
if (!existingUser) {
|
||||
const error = new Error('User not found');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedName = String(name ?? existingUser.name).trim();
|
||||
const normalizedEmail = normalizeEmail(email ?? existingUser.email);
|
||||
const normalizedIsActive = typeof isActive === 'boolean' ? isActive : existingUser.is_active;
|
||||
const normalizedPassword = typeof password === 'string' ? password.trim() : '';
|
||||
|
||||
if (!normalizedName) {
|
||||
const error = new Error('Name is required');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
|
||||
const error = new Error('Valid email is required');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (normalizedPassword && normalizedPassword.length < 6) {
|
||||
const error = new Error('Password must have at least 6 characters');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const passwordHash = normalizedPassword ? hashPassword(normalizedPassword) : existingUser.password_hash;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE app_users
|
||||
SET name = $1,
|
||||
email = $2,
|
||||
password_hash = $3,
|
||||
is_active = $4,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5
|
||||
RETURNING id, name, email, is_active, created_at, updated_at`,
|
||||
[normalizedName, normalizedEmail, passwordHash, normalizedIsActive, id]
|
||||
);
|
||||
|
||||
return publicUserFields(result.rows[0]);
|
||||
} catch (error) {
|
||||
if (error.code === '23505') {
|
||||
const duplicateError = new Error('A user with this email already exists');
|
||||
duplicateError.statusCode = 409;
|
||||
throw duplicateError;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (id) => {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM app_users
|
||||
WHERE id = $1
|
||||
RETURNING id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!result.rowCount) {
|
||||
const error = new Error('User not found');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createUser,
|
||||
deleteUser,
|
||||
findUserById,
|
||||
findUserByEmail,
|
||||
generatePassword,
|
||||
listUsers,
|
||||
normalizeEmail,
|
||||
publicUserFields,
|
||||
updateUser,
|
||||
verifyPassword
|
||||
};
|
||||
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 { 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user