diff --git a/apps/api/src/controllers/usuarios.controller.ts b/apps/api/src/controllers/usuarios.controller.ts index 57cf2c9..fc049ce 100644 --- a/apps/api/src/controllers/usuarios.controller.ts +++ b/apps/api/src/controllers/usuarios.controller.ts @@ -1,6 +1,21 @@ import { Request, Response, NextFunction } from 'express'; import * as usuariosService from '../services/usuarios.service.js'; import { AppError } from '../utils/errors.js'; +import { prisma } from '../config/database.js'; + +// RFC del tenant administrador global +const ADMIN_TENANT_RFC = 'AASI940812GM6'; + +async function isGlobalAdmin(req: Request): Promise { + if (req.user!.role !== 'admin') return false; + + const tenant = await prisma.tenant.findUnique({ + where: { id: req.user!.tenantId }, + select: { rfc: true }, + }); + + return tenant?.rfc === ADMIN_TENANT_RFC; +} export async function getUsuarios(req: Request, res: Response, next: NextFunction) { try { @@ -11,6 +26,21 @@ export async function getUsuarios(req: Request, res: Response, next: NextFunctio } } +/** + * Obtiene todos los usuarios de todas las empresas (solo admin global) + */ +export async function getAllUsuarios(req: Request, res: Response, next: NextFunction) { + try { + if (!(await isGlobalAdmin(req))) { + throw new AppError(403, 'Solo el administrador global puede ver todos los usuarios'); + } + const usuarios = await usuariosService.getAllUsuarios(); + res.json(usuarios); + } catch (error) { + next(error); + } +} + export async function inviteUsuario(req: Request, res: Response, next: NextFunction) { try { if (req.user!.role !== 'admin') { @@ -28,7 +58,8 @@ export async function updateUsuario(req: Request, res: Response, next: NextFunct if (req.user!.role !== 'admin') { throw new AppError(403, 'Solo administradores pueden modificar usuarios'); } - const usuario = await usuariosService.updateUsuario(req.user!.tenantId, req.params.id, req.body); + const userId = req.params.id as string; + const usuario = await usuariosService.updateUsuario(req.user!.tenantId, userId, req.body); res.json(usuario); } catch (error) { next(error); @@ -40,10 +71,49 @@ export async function deleteUsuario(req: Request, res: Response, next: NextFunct if (req.user!.role !== 'admin') { throw new AppError(403, 'Solo administradores pueden eliminar usuarios'); } - if (req.params.id === req.user!.id) { + const userId = req.params.id as string; + if (userId === req.user!.userId) { throw new AppError(400, 'No puedes eliminar tu propia cuenta'); } - await usuariosService.deleteUsuario(req.user!.tenantId, req.params.id); + await usuariosService.deleteUsuario(req.user!.tenantId, userId); + res.status(204).send(); + } catch (error) { + next(error); + } +} + +/** + * Actualiza un usuario globalmente (puede cambiar de empresa) + */ +export async function updateUsuarioGlobal(req: Request, res: Response, next: NextFunction) { + try { + if (!(await isGlobalAdmin(req))) { + throw new AppError(403, 'Solo el administrador global puede modificar usuarios globalmente'); + } + const userId = req.params.id as string; + if (userId === req.user!.userId && req.body.tenantId) { + throw new AppError(400, 'No puedes cambiar tu propia empresa'); + } + const usuario = await usuariosService.updateUsuarioGlobal(userId, req.body); + res.json(usuario); + } catch (error) { + next(error); + } +} + +/** + * Elimina un usuario globalmente + */ +export async function deleteUsuarioGlobal(req: Request, res: Response, next: NextFunction) { + try { + if (!(await isGlobalAdmin(req))) { + throw new AppError(403, 'Solo el administrador global puede eliminar usuarios globalmente'); + } + const userId = req.params.id as string; + if (userId === req.user!.userId) { + throw new AppError(400, 'No puedes eliminar tu propia cuenta'); + } + await usuariosService.deleteUsuarioGlobal(userId); res.status(204).send(); } catch (error) { next(error); diff --git a/apps/api/src/routes/usuarios.routes.ts b/apps/api/src/routes/usuarios.routes.ts index a9804e0..35733b9 100644 --- a/apps/api/src/routes/usuarios.routes.ts +++ b/apps/api/src/routes/usuarios.routes.ts @@ -6,9 +6,15 @@ const router = Router(); router.use(authenticate); +// Rutas por tenant router.get('/', usuariosController.getUsuarios); router.post('/invite', usuariosController.inviteUsuario); router.patch('/:id', usuariosController.updateUsuario); router.delete('/:id', usuariosController.deleteUsuario); +// Rutas globales (solo admin global) +router.get('/global/all', usuariosController.getAllUsuarios); +router.patch('/global/:id', usuariosController.updateUsuarioGlobal); +router.delete('/global/:id', usuariosController.deleteUsuarioGlobal); + export { router as usuariosRoutes }; diff --git a/apps/api/src/services/usuarios.service.ts b/apps/api/src/services/usuarios.service.ts index 21129b5..db6bf61 100644 --- a/apps/api/src/services/usuarios.service.ts +++ b/apps/api/src/services/usuarios.service.ts @@ -105,3 +105,93 @@ export async function deleteUsuario(tenantId: string, userId: string): Promise { + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + nombre: true, + role: true, + active: true, + lastLogin: true, + createdAt: true, + tenantId: true, + tenant: { + select: { + nombre: true, + }, + }, + }, + orderBy: [{ tenant: { nombre: 'asc' } }, { createdAt: 'desc' }], + }); + + return users.map(u => ({ + id: u.id, + email: u.email, + nombre: u.nombre, + role: u.role, + active: u.active, + lastLogin: u.lastLogin?.toISOString() || null, + createdAt: u.createdAt.toISOString(), + tenantId: u.tenantId, + tenantName: u.tenant.nombre, + })); +} + +/** + * Actualiza un usuario globalmente (puede cambiar de tenant) + */ +export async function updateUsuarioGlobal( + userId: string, + data: UserUpdate & { tenantId?: string } +): Promise { + const user = await prisma.user.update({ + where: { id: userId }, + data: { + ...(data.nombre && { nombre: data.nombre }), + ...(data.role && { role: data.role }), + ...(data.active !== undefined && { active: data.active }), + ...(data.tenantId && { tenantId: data.tenantId }), + }, + select: { + id: true, + email: true, + nombre: true, + role: true, + active: true, + lastLogin: true, + createdAt: true, + tenantId: true, + tenant: { + select: { + nombre: true, + }, + }, + }, + }); + + return { + id: user.id, + email: user.email, + nombre: user.nombre, + role: user.role, + active: user.active, + lastLogin: user.lastLogin?.toISOString() || null, + createdAt: user.createdAt.toISOString(), + tenantId: user.tenantId, + tenantName: user.tenant.nombre, + }; +} + +/** + * Elimina un usuario globalmente + */ +export async function deleteUsuarioGlobal(userId: string): Promise { + await prisma.user.delete({ + where: { id: userId }, + }); +} diff --git a/apps/web/app/(dashboard)/admin/usuarios/page.tsx b/apps/web/app/(dashboard)/admin/usuarios/page.tsx new file mode 100644 index 0000000..8b576bc --- /dev/null +++ b/apps/web/app/(dashboard)/admin/usuarios/page.tsx @@ -0,0 +1,305 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios'; +import { getTenants, type Tenant } from '@/lib/api/tenants'; +import { useAuthStore } from '@/stores/auth-store'; +import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const roleLabels = { + admin: { label: 'Administrador', icon: Shield, color: 'text-primary' }, + contador: { label: 'Contador', icon: Calculator, color: 'text-green-600' }, + visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' }, +}; + +interface EditingUser { + id: string; + nombre: string; + role: 'admin' | 'contador' | 'visor'; + tenantId: string; +} + +export default function AdminUsuariosPage() { + const { user: currentUser } = useAuthStore(); + const { data: usuarios, isLoading, error } = useAllUsuarios(); + const updateUsuario = useUpdateUsuarioGlobal(); + const deleteUsuario = useDeleteUsuarioGlobal(); + + const [tenants, setTenants] = useState([]); + const [editingUser, setEditingUser] = useState(null); + const [filterTenant, setFilterTenant] = useState('all'); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + getTenants().then(setTenants).catch(console.error); + }, []); + + const handleEdit = (usuario: any) => { + setEditingUser({ + id: usuario.id, + nombre: usuario.nombre, + role: usuario.role, + tenantId: usuario.tenantId, + }); + }; + + const handleSave = async () => { + if (!editingUser) return; + try { + await updateUsuario.mutateAsync({ + id: editingUser.id, + data: { + nombre: editingUser.nombre, + role: editingUser.role, + tenantId: editingUser.tenantId, + }, + }); + setEditingUser(null); + } catch (err: any) { + alert(err.response?.data?.error || 'Error al actualizar usuario'); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Estas seguro de eliminar este usuario?')) return; + try { + await deleteUsuario.mutateAsync(id); + } catch (err: any) { + alert(err.response?.data?.error || 'Error al eliminar usuario'); + } + }; + + const filteredUsuarios = usuarios?.filter(u => { + const matchesTenant = filterTenant === 'all' || u.tenantId === filterTenant; + const matchesSearch = !searchTerm || + u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) || + u.email.toLowerCase().includes(searchTerm.toLowerCase()); + return matchesTenant && matchesSearch; + }); + + // Agrupar por empresa + const groupedByTenant = filteredUsuarios?.reduce((acc, u) => { + const key = u.tenantId || 'sin-empresa'; + if (!acc[key]) { + acc[key] = { + tenantName: u.tenantName || 'Sin empresa', + users: [], + }; + } + acc[key].users.push(u); + return acc; + }, {} as Record); + + if (error) { + return ( + + + +

+ No tienes permisos para ver esta pagina o ocurrio un error. +

+
+
+
+ ); + } + + return ( + +
+ {/* Filtros */} + + +
+
+ setSearchTerm(e.target.value)} + /> +
+
+ +
+
+
+
+ + {/* Stats */} +
+
+ + {filteredUsuarios?.length || 0} usuarios +
+
+ + {Object.keys(groupedByTenant || {}).length} empresas +
+
+ + {/* Users by tenant */} + {isLoading ? ( + + + Cargando usuarios... + + + ) : ( + Object.entries(groupedByTenant || {}).map(([tenantId, { tenantName, users }]) => ( + + + + + {tenantName} + + ({users?.length} usuarios) + + + + +
+ {users?.map(usuario => { + const roleInfo = roleLabels[usuario.role]; + const RoleIcon = roleInfo.icon; + const isCurrentUser = usuario.id === currentUser?.id; + const isEditing = editingUser?.id === usuario.id; + + return ( +
+
+
+ {usuario.nombre.charAt(0).toUpperCase()} +
+
+ {isEditing ? ( +
+ setEditingUser({ ...editingUser, nombre: e.target.value })} + className="h-8" + /> +
+ + +
+
+ ) : ( + <> +
+ {usuario.nombre} + {isCurrentUser && ( + Tu + )} + {!usuario.active && ( + Inactivo + )} +
+
{usuario.email}
+ + )} +
+
+
+ {!isEditing && ( +
+ + {roleInfo.label} +
+ )} + {!isCurrentUser && ( +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+ )} +
+
+ ); + })} +
+
+
+ )) + )} +
+
+ ); +} diff --git a/apps/web/components/layouts/sidebar.tsx b/apps/web/components/layouts/sidebar.tsx index d38a1a8..3f2e81f 100644 --- a/apps/web/components/layouts/sidebar.tsx +++ b/apps/web/components/layouts/sidebar.tsx @@ -15,6 +15,7 @@ import { Bell, Users, Building2, + UserCog, } from 'lucide-react'; import { useAuthStore } from '@/stores/auth-store'; import { logout } from '@/lib/api/auth'; @@ -33,6 +34,7 @@ const navigation = [ const adminNavigation = [ { name: 'Clientes', href: '/clientes', icon: Building2 }, + { name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog }, ]; export function Sidebar() { diff --git a/apps/web/lib/api/usuarios.ts b/apps/web/lib/api/usuarios.ts index 2c57985..c0c4442 100644 --- a/apps/web/lib/api/usuarios.ts +++ b/apps/web/lib/api/usuarios.ts @@ -19,3 +19,18 @@ export async function updateUsuario(id: string, data: UserUpdate): Promise { await apiClient.delete(`/usuarios/${id}`); } + +// Funciones globales (admin global) +export async function getAllUsuarios(): Promise { + const response = await apiClient.get('/usuarios/global/all'); + return response.data; +} + +export async function updateUsuarioGlobal(id: string, data: UserUpdate): Promise { + const response = await apiClient.patch(`/usuarios/global/${id}`, data); + return response.data; +} + +export async function deleteUsuarioGlobal(id: string): Promise { + await apiClient.delete(`/usuarios/global/${id}`); +} diff --git a/apps/web/lib/hooks/use-usuarios.ts b/apps/web/lib/hooks/use-usuarios.ts index 31721dc..b3cc790 100644 --- a/apps/web/lib/hooks/use-usuarios.ts +++ b/apps/web/lib/hooks/use-usuarios.ts @@ -38,3 +38,31 @@ export function useDeleteUsuario() { }, }); } + +// Hooks globales (admin global) +export function useAllUsuarios() { + return useQuery({ + queryKey: ['usuarios', 'global'], + queryFn: usuariosApi.getAllUsuarios, + }); +} + +export function useUpdateUsuarioGlobal() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UserUpdate }) => usuariosApi.updateUsuarioGlobal(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['usuarios'] }); + }, + }); +} + +export function useDeleteUsuarioGlobal() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => usuariosApi.deleteUsuarioGlobal(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['usuarios'] }); + }, + }); +} diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts index e5f6fae..e5e90b3 100644 --- a/packages/shared/src/types/user.ts +++ b/packages/shared/src/types/user.ts @@ -38,12 +38,15 @@ export interface UserListItem { active: boolean; lastLogin: string | null; createdAt: string; + tenantId?: string; + tenantName?: string; } export interface UserUpdate { nombre?: string; role?: 'admin' | 'contador' | 'visor'; active?: boolean; + tenantId?: string; } export interface AuditLog {